Skip to content

Rebuild FostGen on Next 16 with a layered architecture - #2

Open
idugeni wants to merge 1 commit into
mainfrom
feat/v3-modernization
Open

Rebuild FostGen on Next 16 with a layered architecture#2
idugeni wants to merge 1 commit into
mainfrom
feat/v3-modernization

Conversation

@idugeni

@idugeni idugeni commented Jul 31, 2026

Copy link
Copy Markdown
Owner

This pull request was created by @kiro-agent on behalf of @idugeni 👻

Comment with /kiro fix to address specific feedback or /kiro all to address everything.
Learn about Kiro Web


Full modernisation pass: dependencies, architecture, correctness, features, accessibility and tests.

Upgrades

Package Before After
next 15.3.8 16.2.12
react / react-dom 19.1 19.2
tailwindcss 4.1 4.3
lucide-react 0.487 1.28
jest 29 (babel-jest) 30 (next/jest)
eslint-config-next 15.3 16.2 (native flat config)
typescript 5.x 5.9
  • next lint no longer exists in Next 16, so eslint.config.mjs now consumes the flat-config arrays from eslint-config-next directly and npm run lint calls eslint itself.
  • Deliberately stayed on TypeScript 5.9: Next 16 does not yet detect TypeScript 7 and aborts the build (vercel/next.js#95490).
  • tsconfig hardened with noUncheckedIndexedAccess, noImplicitOverride, noFallthroughCasesInSwitch, ES2022 target.

Dead code removed

Each of these was not just unused but non-functional:

  • winston + @types/expresssrc/utils/logger.ts registered Express middleware in a Next.js app. Replaced by a dependency-free isomorphic logger.
  • The Turbopack @svgr/webpack rule in next.config.ts referenced a package that was never installed; it would have failed on the first SVG import.
  • turbo — no workspaces, and no script ever invoked it.
  • src/components/__tests__/InputForm.test.tsx — asserted an API the component never had (rendered <InputForm onSubmit> only and expected internal URL validation). It could not compile, let alone pass.

Architecture

browser --> GET /api/structure --> GitHub REST API
                  |
                  +-> { repository, ref, path, nodes[], truncated, rateLimit }
                            |
                            +-> deriveStructure()  filter -> aggregate -> sort -> render
  • src/lib/github — URL parser (shorthand, SSH remotes, raw./api. hosts, /tree/+/blob/ deep links), typed API client with optional GITHUB_TOKEN, rate-limit surfacing, and automatic ref widening for branch names containing slashes (release/2026-07).
  • src/lib/treebuild -> filter -> sort -> render as pure, independently tested stages composed by deriveStructure.
  • GET /api/structure — zod-validated query, cache headers, and a structured error taxonomy (AppError with 12 codes).

GitHub is now called from the server: the token stays private, responses are cached and shared, and anonymous visitors are not billed against their own per-IP quota. The response carries the unfiltered tree, so changing a format or depth limit re-renders instantly with no refetch.

Correctness fixes

  • Directories are identified by GitHub's type field instead of being inferred from "has children", so empty directories stay directories (previously rendered as files).
  • Submodules (type: 'commit') are a distinct node type rather than being mislabelled as files.
  • A truncated tree degrades with a warning instead of throwing Repository is too large.
  • Notification timeouts are cleared on unmount, so no state updates land after teardown.
  • useLocalStorage and the theme provider use useSyncExternalStore, which removes the hydration mismatch and the cascading render from setState-in-effect.

Features

  • Five output formats: ASCII tree, Markdown list, JSON, YAML, flat paths.
  • Optional code fence, trailing slashes, file sizes with directory aggregation.
  • Depth limit, folders-only mode, three sort orders.
  • gitignore-style ignore patterns (*, **, ?, trailing /, ! negation) with a default noise list that can be switched off.
  • Repository summary (stars, forks, language, license, last push), live stats, recent repositories, light/dark/system theme with no first-paint flash, keyboard shortcuts.
  • robots.ts, sitemap.ts, manifest.ts, JSON-LD, OpenGraph metadata, error and not-found boundaries, and baseline security headers.

Accessibility

Labelled controls, role="switch" toggles, dismissible live-region toasts, and validation errors wired to their field via aria-describedby/aria-invalid.

Testing

169 tests across 13 suites, ~80% coverage (70% floor enforced via coverageThreshold), replacing the one broken test file. Added a GitHub Actions workflow running lint, typecheck, coverage and a production build.

Verification performed

  • npm run lint, npm run typecheck, npm test — all clean.
  • npm run build — succeeds (Turbopack, 6 routes).
  • Live end-to-end against the real GitHub API via next start:
    • ?url=idugeni/fostgen -> 200, 36 entries, correct Cache-Control.
    • ?url=vercel/next.js&ref=canary&path=packages/next-env -> 200, scoped correctly.
    • Error paths return the right status and code: INVALID_REQUEST 400, INVALID_URL 400, NOT_FOUND 404, BRANCH_NOT_FOUND 404, PATH_NOT_FOUND 404.
    • Security headers present, x-powered-by absent, /does-not-exist -> 404.
  • Headless browser walkthrough in both themes: typed a repo, generated, verified the rendered ASCII tree, stats row (Folders 6 / Files 29 / Depth 4 / Size 402.9 KB), summary panel, and format switching without a refetch.

Notes for the reviewer

  • .gitignore now un-ignores .env.example.
  • next build rewrote tsconfig.json (jsx: react-jsx, .next/dev/types); those edits are Next's own and are committed as-is.
  • GITHUB_TOKEN is optional. Without it the app still works at GitHub's 60 requests/hour anonymous limit, and the UI warns when the remaining quota gets low.

Summary by CodeRabbit

  • New Features

    • Generate repository structures from GitHub URLs, branches, tags, commits, or subdirectories.
    • Export results as ASCII, Markdown, JSON, YAML, or file paths.
    • Customize sorting, depth, ignored patterns, file visibility, sizes, and formatting.
    • View repository details, statistics, recent repositories, and truncation warnings.
    • Added light, dark, and system themes with improved accessibility and responsive design.
    • Added copy, download, keyboard shortcuts, error recovery, and notifications.
  • Documentation

    • Expanded setup, configuration, usage, API, accessibility, and contribution guidance.
  • Tests

    • Added comprehensive coverage for generation, rendering, validation, and error handling.

Upgrade every dependency to the current stable release, restructure the app
into domain/hook/component layers, move GitHub access to the server, and
replace the placeholder test suite with real coverage.

Upgrades
- Next 15.3.8 -> 16.2.12, React 19.1 -> 19.2, Tailwind 4.1 -> 4.3,
  lucide-react 0.487 -> 1.28, Jest 29 -> 30 (via next/jest), TypeScript 5.9.
- ESLint moved to a native flat config using eslint-config-next 16
  (next lint no longer exists in Next 16).
- tsconfig hardened: noUncheckedIndexedAccess, noImplicitOverride,
  noFallthroughCasesInSwitch, ES2022 target.
- Pinned to TypeScript 5.9: Next 16 does not yet detect TypeScript 7.

Removals
- winston + @types/express: src/utils/logger.ts registered Express
  middleware in a Next.js app, so it could never run.
- The turbopack @svgr/webpack rule referenced a package that was never
  installed and would fail on the first SVG import.
- turbo: no workspaces, and no script ever invoked it.
- src/components/__tests__/InputForm.test.tsx asserted an API the component
  never had (it passed only onSubmit and expected internal validation).

Architecture
- src/lib/github: URL parser (shorthand, SSH, raw, api, deep links), typed
  API client with optional GITHUB_TOKEN, rate-limit surfacing and ref
  widening for branch names containing slashes.
- src/lib/tree: build -> filter -> sort -> render as pure, tested stages,
  composed by deriveStructure.
- GET /api/structure resolves repositories server-side with a zod-validated
  query, cache headers and a structured error taxonomy (AppError).

Correctness fixes
- Directories are identified by GitHub's type field instead of being
  inferred from "has children", so empty directories stay directories.
- Submodules are a distinct node type rather than being shown as files.
- A truncated tree now degrades with a warning instead of throwing.
- Notification timeouts are cleared, so no state updates after unmount.

Features
- Five output formats (ASCII, Markdown, JSON, YAML, flat paths), optional
  code fence, sizes with directory aggregation, depth limit, folders-only
  mode, three sort orders and gitignore-style ignore patterns with negation.
- Repository summary, live stats, recent repositories, light/dark/system
  theme with no first-paint flash, and keyboard shortcuts.
- Option changes re-render from data already in memory; no refetch.

Accessibility
- Labelled controls, role="switch" toggles, live-region toasts that can be
  dismissed, and validation errors wired to their field.

Testing and CI
- 169 tests across 13 suites, ~80% coverage with a 70% floor.
- GitHub Actions workflow running lint, typecheck, tests and a build.

Co-authored-by: Irma Raihan Setiawan <officialelsa21@gmail.com>
@vercel

vercel Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
fostgen Error Error Jul 31, 2026 3:40am

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

FostGen v3 adds GitHub repository parsing, structure retrieval, tree transformation, multiple output formats, a client generator interface, theme and notification systems, application metadata, and CI verification.

Changes

FostGen application rebuild

Layer / File(s) Summary
Domain contracts and GitHub retrieval
src/lib/api/schema.ts, src/lib/config.ts, src/lib/errors.ts, src/lib/github/*, src/lib/logger.ts, src/lib/format/*
Adds validated contracts, normalized errors, configuration parsing, GitHub URL handling, repository retrieval, branch and path resolution, logging, and formatting utilities.
Tree transformation and rendering
src/lib/tree/*, src/lib/utils/*
Builds hierarchical trees, applies ignore patterns and depth limits, sorts nodes, computes statistics, and renders ASCII, Markdown, JSON, YAML, and path outputs.
Structure API delivery
src/app/api/structure/*
Adds request validation, GitHub structure resolution, tree payload construction, cache headers, structured errors, rate-limit handling, and route tests.
Client generator flow
src/components/generator/*, src/hooks/*
Adds repository input, generator options, recent repositories, output controls, persisted state, clipboard support, request lifecycle handling, notifications, and integration tests.
UI, theme, and notifications
src/components/ui/*, src/components/theme/*, src/components/layout/*, src/app/globals.css
Adds reusable controls, toast notifications, system/light/dark themes, shared navigation, accessibility styles, and responsive visual tokens.
Application shell and verification
src/app/*, next.config.ts, package.json, eslint.config.mjs, jest.config.js, .github/workflows/ci.yml, README.md
Updates the landing page, metadata routes, security headers, package scripts, lint and test configuration, CI workflow, environment documentation, TypeScript settings, and project documentation.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant StructureGenerator
  participant StructureAPI
  participant GitHubAPI
  participant TreePipeline
  participant OutputPanel
  User->>StructureGenerator: Enter repository URL and options
  StructureGenerator->>StructureAPI: Request repository structure
  StructureAPI->>GitHubAPI: Fetch metadata and tree
  GitHubAPI-->>StructureAPI: Return repository and tree data
  StructureAPI->>TreePipeline: Build and derive structure
  TreePipeline-->>StructureAPI: Return rendered output and statistics
  StructureAPI-->>StructureGenerator: Return structure payload
  StructureGenerator->>OutputPanel: Render output and statistics
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: rebuilding FostGen on Next 16 with a layered architecture.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🛠️ Fix failing CI checks 💡
  • Fix failing CI checks
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/v3-modernization

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 286fc4ceb7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/github/client.ts
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': USER_AGENT,
};
if (token) headers.Authorization = `Bearer ${token}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not proxy private repositories with the server token

When GITHUB_TOKEN is configured with private-repository access, this unconditional Authorization header makes every anonymous /api/structure request run with that token; a visitor who guesses owner/private-repo can receive the private tree, and the route then marks successful responses public-cacheable. Please either require caller authorization for private repositories or restrict/reject token-backed private repo responses.

Useful? React with 👍 / 👎.

Comment thread src/lib/github/client.ts
};
} catch (cause) {
const error = toAppError(cause);
if (error.code !== 'NOT_FOUND') throw error;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Continue ref widening after path misses

When a repository has both a ref named release and a ref named release/2026-07, a pasted GitHub URL like /tree/release/2026-07/src is parsed as ref=release and path=2026-07/src; fetchTree('release') succeeds, scopeToPath throws PATH_NOT_FOUND, and this check rethrows instead of trying the next widened candidate release/2026-07. Treat PATH_NOT_FOUND during widening as a candidate miss until the wider ref candidates are exhausted, otherwise valid GitHub deep links fail whenever a prefix ref exists.

Useful? React with 👍 / 👎.

Comment thread src/lib/tree/pipeline.ts
patterns: resolveIgnorePatterns(options),
});

const sized = options.showSizes ? withAggregatedSizes(filtered) : filtered;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Aggregate sizes before size-desc sorting

When the user selects Largest first but leaves Show sizes off (the default), directories still have undefined size here, so sortTree(..., 'size-desc') treats them as zero and can place large folders after small root files. Aggregate directory sizes whenever sorting by size, while still passing showSizes separately to rendering so the numbers can remain hidden.

Useful? React with 👍 / 👎.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Modernize FostGen to Next.js 16 with layered server GitHub pipeline

✨ Enhancement 🧪 Tests ⚙️ Configuration changes 📝 Documentation 🐞 Bug fix 🕐 40+ Minutes

Grey Divider

AI Description

• Upgrade to Next 16/React 19.2, modern lint/test tooling, and stricter TypeScript checks.
• Move GitHub access to a cached server API with typed errors and rate-limit surfacing.
• Rebuild the UI around a pure tree pipeline plus new options, accessibility, and real tests.
Diagram

graph TD
  UI["Client generator UI"] --> API["GET /api/structure"] --> GHClient["GitHub client"] --> GHAPI{{"GitHub REST API"}}
  API --> TreeBuild["Tree builder"]
  UI --> Pipeline["Derive pipeline"]
  UI --> Storage[("localStorage")]
  subgraph Legend
    direction LR
    _ui["UI"] ~~~ _api["API/Module"] ~~~ _db[("Storage")] ~~~ _ext{{"External"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Use Octokit (@octokit/rest) instead of a custom GitHub client
  • ➕ Reduces custom HTTP/error-handling surface area
  • ➕ Leverages a widely used GitHub-maintained client
  • ➖ Heavier dependency footprint and more transitive deps
  • ➖ Still needs custom URL parsing/ref-widening and the app’s error taxonomy
2. Use GitHub GraphQL to fetch metadata + tree in fewer calls
  • ➕ Potentially fewer round trips with a single query
  • ➕ Schema-driven typing for GitHub responses
  • ➖ More complex query/auth setup and error handling
  • ➖ Harder to replicate the ref/path ambiguity handling and simple caching semantics

Recommendation: The PR’s approach (small custom REST client + explicit AppError taxonomy + server-side caching + pure client derivation) is well-suited for a single-purpose tool and keeps dependencies light. Octokit/GraphQL are reasonable if GitHub integration expands, but would add complexity without clear benefit at current scope.

Files changed (70) +6403 / -377

Enhancement (42) +3873 / -183
route.tsAdd cached server API endpoint to resolve repo structure +83/-0

Add cached server API endpoint to resolve repo structure

• Implements GET /api/structure with zod-validated query, server-side GitHub resolution, nested tree building, CDN cache headers, and structured error responses.

src/app/api/structure/route.ts

error.tsxAdd App Router error boundary page with retry +38/-0

Add App Router error boundary page with retry

• Introduces a user-facing error boundary with structured logging and an accessible retry action.

src/app/error.tsx

layout.tsxRevamp root layout metadata, theming bootstrap, and providers +74/-41

Revamp root layout metadata, theming bootstrap, and providers

• Adds richer metadata/viewport settings, injects a pre-hydration theme script, and wraps the app with ThemeProvider and ToastProvider.

src/app/layout.tsx

manifest.tsAdd manifest metadata route +22/-0

Add manifest metadata route

• Adds a Next MetadataRoute manifest derived from centralized siteConfig.

src/app/manifest.ts

not-found.tsxAdd custom 404 page +25/-0

Add custom 404 page

• Adds a styled not-found page with metadata and a link back to the generator.

src/app/not-found.tsx

page.tsxReplace legacy generator page with new StructureGenerator experience +72/-142

Replace legacy generator page with new StructureGenerator experience

• Moves to the new generator component composition, adds a feature grid, and injects JSON-LD structured data using site config.

src/app/page.tsx

robots.tsAdd robots.txt metadata route +13/-0

Add robots.txt metadata route

• Implements a Next MetadataRoute robots handler using the resolved public site URL.

src/app/robots.ts

sitemap.tsAdd sitemap.xml metadata route +13/-0

Add sitemap.xml metadata route

• Implements a Next MetadataRoute sitemap handler using centralized site config and derived origin.

src/app/sitemap.ts

ErrorNotice.tsxAdd inline error renderer for generator failures +23/-0

Add inline error renderer for generator failures

• Adds a dedicated UI component to show structured errors and hints in the generator flow.

src/components/generator/ErrorNotice.tsx

OptionsPanel.tsxImplement output options panel (format, sort, depth, ignores) +164/-0

Implement output options panel (format, sort, depth, ignores)

• Adds controls for format/sort/depth, toggles for sizes/trailing slash/fences, and gitignore-style ignore patterns input.

src/components/generator/OptionsPanel.tsx

OutputPanel.tsxAdd output viewer with stats and copy/download controls +162/-0

Add output viewer with stats and copy/download controls

• Renders generated output with optional wrapping, line counts, stats, skeleton loading, and copy/download actions.

src/components/generator/OutputPanel.tsx

RecentRepositories.tsxAdd recent repository shortcuts list +61/-0

Add recent repository shortcuts list

• Introduces a UI component for selecting from locally remembered recent repositories.

src/components/generator/RecentRepositories.tsx

RepoForm.tsxAdd accessible repo form with advanced branch/path inputs +182/-0

Add accessible repo form with advanced branch/path inputs

• Adds repository input form with validation wiring, example shortcuts, and expandable ref/path fields for advanced targeting.

src/components/generator/RepoForm.tsx

RepositorySummary.tsxAdd repository metadata summary and warnings +128/-0

Add repository metadata summary and warnings

• Displays resolved repository details, ref/path badges, truncation warnings, and low anonymous rate-limit warnings.

src/components/generator/RepositorySummary.tsx

StructureGenerator.tsxCompose generator: fetch once, derive locally on option changes +276/-0

Compose generator: fetch once, derive locally on option changes

• Coordinates request lifecycle, localStorage-backed options/recents, pure deriveStructure recomputation, keyboard shortcuts, toasts, and copy/download behavior.

src/components/generator/StructureGenerator.tsx

GithubMark.tsxAdd reusable GitHub mark icon +20/-0

Add reusable GitHub mark icon

• Adds a dedicated GitHub icon component used across layout and forms.

src/components/icons/GithubMark.tsx

Footer.tsxAdd application footer component +39/-0

Add application footer component

• Introduces a footer consistent with the new layout and theming system.

src/components/layout/Footer.tsx

Header.tsxAdd sticky header with GitHub link and theme toggle +40/-0

Add sticky header with GitHub link and theme toggle

• Adds a new header with branding, GitHub repo link, keyboard shortcut hint, and ThemeToggle integration.

src/components/layout/Header.tsx

ThemeProvider.tsxImplement theme preference store with pre-paint bootstrap +150/-0

Implement theme preference store with pre-paint bootstrap

• Adds system/light/dark theming using useSyncExternalStore, localStorage persistence, cross-tab sync, and a head bootstrap script to avoid FOUC.

src/components/theme/ThemeProvider.tsx

ThemeToggle.tsxAdd theme toggle control +39/-0

Add theme toggle control

• Adds a button that cycles theme preference with accessible labeling and icons.

src/components/theme/ThemeToggle.tsx

Badge.tsxAdd Badge UI primitive +38/-0

Add Badge UI primitive

• Introduces a badge component for compact metadata chips (ref/path/status).

src/components/ui/Badge.tsx

Button.tsxAdd Button UI primitive with variants and loading state +58/-0

Add Button UI primitive with variants and loading state

• Introduces a reusable button supporting variants/sizes/icons and a consistent loading UX.

src/components/ui/Button.tsx

Select.tsxAdd typed Select component +74/-0

Add typed Select component

• Adds a generic select component used for format and sorting controls with labels and hints.

src/components/ui/Select.tsx

Spinner.tsxAdd Spinner UI primitive +20/-0

Add Spinner UI primitive

• Adds a spinner component to standardize loading indications across buttons and panels.

src/components/ui/Spinner.tsx

Switch.tsxAdd accessible Switch UI component +64/-0

Add accessible Switch UI component

• Adds a switch control with label/description and ARIA-friendly behavior for option toggles.

src/components/ui/Switch.tsx

Toaster.tsxAdd toast notification system + provider hook +184/-0

Add toast notification system + provider hook

• Implements a lightweight toast manager with tones, timers, accessible roles, and a useToast hook.

src/components/ui/Toaster.tsx

useClipboard.tsAdd clipboard hook with legacy fallback +72/-0

Add clipboard hook with legacy fallback

• Adds a copy-to-clipboard helper that falls back to a hidden textarea + execCommand when navigator.clipboard is unavailable.

src/hooks/useClipboard.ts

useLocalStorage.tsAdd hydration-safe localStorage hook via useSyncExternalStore +128/-0

Add hydration-safe localStorage hook via useSyncExternalStore

• Adds a JSON-backed localStorage hook with validation, cross-tab sync, and hydration-safe server snapshot behavior.

src/hooks/useLocalStorage.ts

useRepoStructure.tsAdd hook managing /api/structure lifecycle with abort safety +149/-0

Add hook managing /api/structure lifecycle with abort safety

• Implements a reducer-driven request lifecycle, typed error parsing, and AbortController handling to prevent out-of-order state updates.

src/hooks/useRepoStructure.ts

schema.tsDefine /api/structure query schema and payload types +52/-0

Define /api/structure query schema and payload types

• Adds zod query validation types plus StructurePayload/ApiErrorPayload definitions and a runtime guard for error payloads.

src/lib/api/schema.ts

config.tsCentralize site config, defaults, and persisted-state parsers +141/-0

Centralize site config, defaults, and persisted-state parsers

• Adds siteConfig, generator defaults, ignore defaults, cache TTL, storage keys, and safe parsers for options and recent repositories.

src/lib/config.ts

errors.tsAdd AppError taxonomy and toAppError normalization +99/-0

Add AppError taxonomy and toAppError normalization

• Introduces explicit error codes mapped to HTTP statuses plus JSON serialization and normalization of thrown values into AppError.

src/lib/errors.ts

units.tsAdd shared formatting helpers (bytes, counts, relative time) +60/-0

Add shared formatting helpers (bytes, counts, relative time)

• Adds utilities used in UI rendering for human-readable sizes, compact counts, and relative timestamps.

src/lib/format/units.ts

types.tsAdd typed GitHub domain models +54/-0

Add typed GitHub domain models

• Defines types for git tree entries, repository metadata, repo coordinates, and rate-limit info shared across layers.

src/lib/github/types.ts

parse-repo-url.tsParse user repo input (URL/SSH/shorthand/deep links) into coordinates +139/-0

Parse user repo input (URL/SSH/shorthand/deep links) into coordinates

• Normalizes various GitHub URL shapes, validates owner/repo, extracts optional ref/path from deep links, and provides helper formatters.

src/lib/github/parse-repo-url.ts

client.tsImplement typed GitHub REST client with caching and ref widening +368/-0

Implement typed GitHub REST client with caching and ref widening

• Adds token-aware requests, rate-limit parsing, structured upstream error mapping, ref/path ambiguity widening for slashy branches, and validated path scoping.

src/lib/github/client.ts

types.tsIntroduce TreeNode domain type +17/-0

Introduce TreeNode domain type

• Defines the canonical TreeNode model used by the API payload, tree pipeline, and renderers.

src/lib/tree/types.ts

filter.tsAdd gitignore-like ignore filtering and depth/file toggles +159/-0

Add gitignore-like ignore filtering and depth/file toggles

• Implements glob/negation pattern compilation plus filtering by depth and file inclusion while keeping directory skeletons.

src/lib/tree/filter.ts

sort.tsAdd recursive sorting modes +37/-0

Add recursive sorting modes

• Implements dirs-first, alphabetical, and size-desc sorting with stable locale-aware comparisons.

src/lib/tree/sort.ts

render.tsRender structures to ASCII/Markdown/JSON/YAML/paths with optional fences +257/-0

Render structures to ASCII/Markdown/JSON/YAML/paths with optional fences

• Adds multi-format renderers, format metadata, YAML-safe scalar quoting, optional fencing, and download filename derivation.

src/lib/tree/render.ts

stats.tsCompute tree statistics for UI display +47/-0

Compute tree statistics for UI display

• Computes directory/file/submodule counts, total file size, and max depth from the filtered/sorted tree.

src/lib/tree/stats.ts

download.tsAdd download helpers for generated output +32/-0

Add download helpers for generated output

• Adds utilities for downloading text output and inferring MIME types from filename extensions.

src/lib/utils/download.ts

Bug fix (1) +109 / -0
build.tsBuild nested tree from GitHub entries and support size aggregation +109/-0

Build nested tree from GitHub entries and support size aggregation

• Converts GitHub’s flat tree listing into nested nodes, preserving empty directories and submodules, and provides aggregated directory sizing.

src/lib/tree/build.ts

Refactor (4) +261 / -29
globals.cssUpdate global styles and theme tokens for new UI +134/-29

Update global styles and theme tokens for new UI

• Refreshes global styling to support the redesigned components, theming via data-theme, and updated layout/typography defaults.

src/app/globals.css

logger.tsReplace winston with a dependency-free isomorphic logger +63/-0

Replace winston with a dependency-free isomorphic logger

• Introduces a lightweight logger with env-based log levels and production JSON output to avoid bundling/Express middleware issues.

src/lib/logger.ts

pipeline.tsCentralize pure deriveStructure pipeline +57/-0

Centralize pure deriveStructure pipeline

• Adds a single pure composition point for filter -> size aggregation -> sort -> render + stats so UI option changes don’t refetch.

src/lib/tree/pipeline.ts

cn.tsAdd Tailwind-safe className combiner +7/-0

Add Tailwind-safe className combiner

• Introduces a 'cn()' helper to merge conditional class names safely (clsx + tailwind-merge).

src/lib/utils/cn.ts

Tests (14) +1759 / -0
jest.setup.tsCreate Jest setup with guarded DOM shims and log silencing +40/-0

Create Jest setup with guarded DOM shims and log silencing

• Adds jest-dom, forces LOG_LEVEL to silent for tests, conditionally shims browser APIs, and resets mocks/storage between tests.

jest.setup.ts

route.test.tsTest /api/structure success, validation, and upstream errors +129/-0

Test /api/structure success, validation, and upstream errors

• Adds node-environment tests covering successful payload shape, subdir scoping, invalid input rejection, 404 mapping, and rate-limit Retry-After handling.

src/app/api/structure/tests/route.test.ts

RepoForm.test.tsxAdd RepoForm unit tests +140/-0

Add RepoForm unit tests

• Covers form rendering, advanced toggle behavior, validation messaging, and user interactions.

src/components/generator/tests/RepoForm.test.tsx

StructureGenerator.test.tsxAdd StructureGenerator behavior tests +240/-0

Add StructureGenerator behavior tests

• Adds integration-style tests for request lifecycle, error surfacing, derived output updates, and user actions.

src/components/generator/tests/StructureGenerator.test.tsx

Toaster.test.tsxAdd tests for toast notifications +127/-0

Add tests for toast notifications

• Tests toast render/dismiss behavior including auto-dismiss and manual dismissal.

src/components/ui/tests/Toaster.test.tsx

config.test.tsTest config helpers and persisted option parsing +85/-0

Test config helpers and persisted option parsing

• Adds tests ensuring config parsing is resilient to malformed/stale localStorage payloads and defaults are applied safely.

src/lib/tests/config.test.ts

units.test.tsAdd unit tests for formatting utilities +57/-0

Add unit tests for formatting utilities

• Covers edge cases for byte formatting, compact count formatting, and relative time output.

src/lib/format/tests/units.test.ts

parse-repo-url.test.tsTest repo URL parsing across supported inputs +91/-0

Test repo URL parsing across supported inputs

• Adds test coverage for shorthand/full URLs/SSH remotes/raw/API/deep links plus invalid input cases.

src/lib/github/tests/parse-repo-url.test.ts

client.test.tsTest GitHub client error mapping and resolution logic +284/-0

Test GitHub client error mapping and resolution logic

• Adds tests covering upstream error conversion, rate-limit behavior, ref widening, and scoping validation.

src/lib/github/tests/client.test.ts

build.test.tsTest tree building correctness +88/-0

Test tree building correctness

• Adds coverage for directory synthesis/order independence, empty directory handling, and leaf typing (file vs submodule).

src/lib/tree/tests/build.test.ts

filter.test.tsTest ignore-pattern filtering semantics +141/-0

Test ignore-pattern filtering semantics

• Covers glob and negation behavior, directory-only patterns, depth limiting, and includeFiles behavior.

src/lib/tree/tests/filter.test.ts

sort.test.tsTest sorting modes for nested trees +77/-0

Test sorting modes for nested trees

• Validates sort modes across nested inputs, including size-desc tie-breaking behavior.

src/lib/tree/tests/sort.test.ts

render.test.tsTest rendering formats and escaping/fencing behavior +183/-0

Test rendering formats and escaping/fencing behavior

• Adds tests for ASCII/Markdown/JSON/YAML/paths output, YAML quoting, and code fence wrapping where supported.

src/lib/tree/tests/render.test.ts

pipeline.test.tsTest end-to-end derivation pipeline behavior +77/-0

Test end-to-end derivation pipeline behavior

• Validates default ignores, fence toggle, stats correctness, depth behavior, and conditional size aggregation.

src/lib/tree/tests/pipeline.test.ts

Documentation (1) +168 / -95
README.mdRewrite README for v3 features, setup, and API usage +168/-95

Rewrite README for v3 features, setup, and API usage

• Updates project positioning, supported inputs/outputs, quick start, configuration, and documents the HTTP API and architecture.

README.md

Other (8) +233 / -70
.env.exampleAdd documented optional environment variables +17/-0

Add documented optional environment variables

• Introduces a sample env file documenting GITHUB_TOKEN, NEXT_PUBLIC_SITE_URL, and LOG_LEVEL and how they affect behavior.

.env.example

ci.ymlAdd CI pipeline for lint, typecheck, tests, and build +58/-0

Add CI pipeline for lint, typecheck, tests, and build

• Adds GitHub Actions jobs to run lint/typecheck/Jest coverage (with artifact upload) and a production build on PRs and main pushes.

.github/workflows/ci.yml

.gitignoreTrack .env.example while ignoring other env files +1/-0

Track .env.example while ignoring other env files

• Keeps .env.example committed while continuing to ignore .env* by default.

.gitignore

eslint.config.mjsMigrate to Next 16 ESLint flat config and tighten rules +46/-11

Migrate to Next 16 ESLint flat config and tighten rules

• Switches to eslint-config-next flat-config exports, adds ignore patterns, and enforces stronger TypeScript/console rules with test overrides.

eslint.config.mjs

jest.config.jsAdopt next/jest and enforce coverage thresholds +18/-10

Adopt next/jest and enforce coverage thresholds

• Migrates Jest to next/jest, switches setup to jest.setup.ts, tunes coverage collection, and adds a global coverage floor.

jest.config.js

next.config.tsRemove broken Turbopack SVG rule and add security headers +30/-7

Remove broken Turbopack SVG rule and add security headers

• Drops the unused Turbopack rules, enables strict mode, disables powered-by, optimizes lucide imports, and applies baseline security headers.

next.config.ts

package.jsonUpgrade framework/tooling dependencies and scripts for v3 +39/-36

Upgrade framework/tooling dependencies and scripts for v3

• Upgrades Next/React/Tailwind/TS/Jest/ESLint, removes dead deps (turbo/winston/express types), adds zod/clsx/tailwind-merge, and modernizes scripts (eslint + verify).

package.json

tsconfig.jsonHarden TypeScript compiler options and include paths +24/-6

Harden TypeScript compiler options and include paths

• Moves target to ES2022, switches JSX mode, enables stricter safety flags, and updates Next type includes for dev/build outputs.

tsconfig.json

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Token allows private leakage 🐞 Bug ⛨ Security
Description
GET /api/structure is unauthenticated but uses a server-side GITHUB_TOKEN for GitHub requests, so
any visitor can fetch structures for private repos that token can read. Successful responses are
also marked Cache-Control: public, which can place private repo structure into shared caches.
Code

src/app/api/structure/route.ts[R65-69]

+    return NextResponse.json(payload, {
+      headers: {
+        'Cache-Control': `public, s-maxage=${GITHUB_CACHE_TTL_SECONDS}, stale-while-revalidate=${GITHUB_CACHE_TTL_SECONDS * 2}`,
+      },
+    });
Evidence
The route has no auth checks and always returns publicly cacheable responses, while the GitHub
client attaches a Bearer token when GITHUB_TOKEN is set; the documentation also states that token
enables access to private repositories.

src/app/api/structure/route.ts[30-69]
src/lib/github/client.ts[61-78]
README.md[90-108]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The API route is publicly callable, yet GitHub requests are made with a privileged server-side `GITHUB_TOKEN` when configured. This allows anonymous callers to enumerate private repositories accessible by that token, and the response is explicitly cacheable as `public`, which can further leak private structures via shared caching.

### Issue Context
- The README explicitly states `GITHUB_TOKEN` “unlocks private repositories the token can read”.
- The route sets `Cache-Control: public, s-maxage=...` for all successful responses.

### Fix Focus Areas
- src/app/api/structure/route.ts[30-69]
- src/lib/github/client.ts[61-78]
- README.md[90-108]

### Suggested fix (pick a deliberate product/security stance)
1) **If private repos should NOT be accessible to anonymous users (recommended for hosted/public deployments):**
  - Require authentication/authorization on `/api/structure` before using `GITHUB_TOKEN` (e.g., session, API key, or other access control).
  - Alternatively, refuse private-repo access entirely: fetch repo metadata, detect `private: true`, and return `UNAUTHORIZED`/`NOT_FOUND` unless the caller is authenticated.

2) **Regardless of access control choice:**
  - Do **not** mark responses as `Cache-Control: public` when the response could be derived from privileged access.
  - At minimum, when `process.env.GITHUB_TOKEN` is set, return `Cache-Control: private, no-store` (or equivalent) for success responses.
  - (Stronger) Add `private` (or `visibility`) to `RepositoryMeta` by mapping the GitHub `/repos` payload, and base cache headers on `repository.private`.

3) **Add tests:**
  - Verify cache headers differ between public vs private repos (or when token is configured).
  - Verify unauthorized callers cannot access private repos when the token can.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Rate limit parsed as zero 🐞 Bug ≡ Correctness
Description
readRateLimit() uses Number(headers.get(...)), so missing x-ratelimit-* headers become 0
(Number(null) === 0) and produce a fake RateLimitInfo instead of null. This can misclassify a 403
without rate-limit headers as RATE_LIMITED and/or show incorrect 0/0/0 rateLimit metadata.
Code

src/lib/github/client.ts[R29-35]

+function readRateLimit(headers: Headers, authenticated: boolean): RateLimitInfo | null {
+  const limit = Number(headers.get('x-ratelimit-limit'));
+  const remaining = Number(headers.get('x-ratelimit-remaining'));
+  const reset = Number(headers.get('x-ratelimit-reset'));
+
+  if (!Number.isFinite(limit) || !Number.isFinite(remaining) || !Number.isFinite(reset)) {
+    return null;
Evidence
The code currently converts potentially-null header values with Number(...), and later uses
rateLimit?.remaining === 0 to detect rate-limiting on 403; if headers are missing this can
incorrectly be true because remaining becomes 0.

src/lib/github/client.ts[29-38]
src/lib/github/client.ts[100-105]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`readRateLimit()` converts missing rate-limit headers to `0` and then treats them as valid because `Number.isFinite(0)` is true. This can cause incorrect `RateLimitInfo` and can trigger the `RATE_LIMITED` path for headerless 403 responses.

### Issue Context
- `Headers.get()` returns `null` when the header is absent.
- `Number(null)` evaluates to `0`, so the current finiteness checks don’t detect missing headers.

### Fix Focus Areas
- src/lib/github/client.ts[29-38]
- src/lib/github/client.ts[100-105]

### Suggested fix
- In `readRateLimit`, read raw header strings first and return `null` if any are `null`/empty.
- Parse using `Number.parseInt(value, 10)` and return `null` if any parsed number is `NaN`.
- Tighten the rate-limit detection to only treat 403 as rate-limited when `rateLimit !== null && rateLimit.remaining === 0`.
- Add/extend a Jest test for a 403 response with *no* x-ratelimit headers to ensure it maps to `UNAUTHORIZED` (not `RATE_LIMITED`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. API error validator incomplete 🐞 Bug ☼ Reliability
Description
isApiErrorPayload() only checks error.message, but the client then trusts error.code and stores
it in state. Malformed/non-conforming responses can therefore yield undefined or unknown error codes
and degrade code-based error handling.
Code

src/lib/api/schema.ts[R44-51]

+export function isApiErrorPayload(value: unknown): value is ApiErrorPayload {
+  if (typeof value !== 'object' || value === null) return false;
+  const candidate = (value as { error?: unknown }).error;
+  return (
+    typeof candidate === 'object' &&
+    candidate !== null &&
+    typeof (candidate as { message?: unknown }).message === 'string'
+  );
Evidence
The predicate claims the payload matches ApiErrorPayload but does not validate error.code; the
hook then reads body.error.code immediately after the guard.

src/lib/api/schema.ts[44-51]
src/hooks/useRepoStructure.ts[60-68]
src/lib/errors.ts[7-23]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`isApiErrorPayload()` is used as a type guard but it doesn’t validate that `error.code` exists or is a member of the `ErrorCode` taxonomy. Downstream, `useRepoStructure` immediately trusts `body.error.code`, which can lead to invalid runtime state when the server/proxy returns a non-conforming payload.

### Issue Context
This is an HTTP boundary; even if the server normally returns the correct shape, clients should defensively validate all fields they rely on.

### Fix Focus Areas
- src/lib/api/schema.ts[44-51]
- src/hooks/useRepoStructure.ts[60-68]
- src/lib/errors.ts[7-23]

### Suggested fix
- Strengthen `isApiErrorPayload` to also validate:
 - `typeof error.code === 'string'` and it is included in `ERROR_CODES`.
 - (Optional) validate `hint` is string when present and `retryAfter` is number when present.
- Alternatively, define a zod schema for the error payload and use `safeParse`.
- In `readError`, if validation fails, fall back to the generic `UPSTREAM` error instead of trusting `code`.
- Add a unit test where the server returns `{ error: { message: 'x' } }` (missing `code`) to ensure the client falls back safely.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment thread src/lib/github/client.ts
Comment on lines +29 to +35
function readRateLimit(headers: Headers, authenticated: boolean): RateLimitInfo | null {
const limit = Number(headers.get('x-ratelimit-limit'));
const remaining = Number(headers.get('x-ratelimit-remaining'));
const reset = Number(headers.get('x-ratelimit-reset'));

if (!Number.isFinite(limit) || !Number.isFinite(remaining) || !Number.isFinite(reset)) {
return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. Rate limit parsed as zero 🐞 Bug ≡ Correctness

readRateLimit() uses Number(headers.get(...)), so missing x-ratelimit-* headers become 0
(Number(null) === 0) and produce a fake RateLimitInfo instead of null. This can misclassify a 403
without rate-limit headers as RATE_LIMITED and/or show incorrect 0/0/0 rateLimit metadata.
Agent Prompt
### Issue description
`readRateLimit()` converts missing rate-limit headers to `0` and then treats them as valid because `Number.isFinite(0)` is true. This can cause incorrect `RateLimitInfo` and can trigger the `RATE_LIMITED` path for headerless 403 responses.

### Issue Context
- `Headers.get()` returns `null` when the header is absent.
- `Number(null)` evaluates to `0`, so the current finiteness checks don’t detect missing headers.

### Fix Focus Areas
- src/lib/github/client.ts[29-38]
- src/lib/github/client.ts[100-105]

### Suggested fix
- In `readRateLimit`, read raw header strings first and return `null` if any are `null`/empty.
- Parse using `Number.parseInt(value, 10)` and return `null` if any parsed number is `NaN`.
- Tighten the rate-limit detection to only treat 403 as rate-limited when `rateLimit !== null && rateLimit.remaining === 0`.
- Add/extend a Jest test for a 403 response with *no* x-ratelimit headers to ensure it maps to `UNAUTHORIZED` (not `RATE_LIMITED`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +65 to +69
return NextResponse.json(payload, {
headers: {
'Cache-Control': `public, s-maxage=${GITHUB_CACHE_TTL_SECONDS}, stale-while-revalidate=${GITHUB_CACHE_TTL_SECONDS * 2}`,
},
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Token allows private leakage 🐞 Bug ⛨ Security

GET /api/structure is unauthenticated but uses a server-side GITHUB_TOKEN for GitHub requests, so
any visitor can fetch structures for private repos that token can read. Successful responses are
also marked Cache-Control: public, which can place private repo structure into shared caches.
Agent Prompt
### Issue description
The API route is publicly callable, yet GitHub requests are made with a privileged server-side `GITHUB_TOKEN` when configured. This allows anonymous callers to enumerate private repositories accessible by that token, and the response is explicitly cacheable as `public`, which can further leak private structures via shared caching.

### Issue Context
- The README explicitly states `GITHUB_TOKEN` “unlocks private repositories the token can read”.
- The route sets `Cache-Control: public, s-maxage=...` for all successful responses.

### Fix Focus Areas
- src/app/api/structure/route.ts[30-69]
- src/lib/github/client.ts[61-78]
- README.md[90-108]

### Suggested fix (pick a deliberate product/security stance)
1) **If private repos should NOT be accessible to anonymous users (recommended for hosted/public deployments):**
   - Require authentication/authorization on `/api/structure` before using `GITHUB_TOKEN` (e.g., session, API key, or other access control).
   - Alternatively, refuse private-repo access entirely: fetch repo metadata, detect `private: true`, and return `UNAUTHORIZED`/`NOT_FOUND` unless the caller is authenticated.

2) **Regardless of access control choice:**
   - Do **not** mark responses as `Cache-Control: public` when the response could be derived from privileged access.
   - At minimum, when `process.env.GITHUB_TOKEN` is set, return `Cache-Control: private, no-store` (or equivalent) for success responses.
   - (Stronger) Add `private` (or `visibility`) to `RepositoryMeta` by mapping the GitHub `/repos` payload, and base cache headers on `repository.private`.

3) **Add tests:**
   - Verify cache headers differ between public vs private repos (or when token is configured).
   - Verify unauthorized callers cannot access private repos when the token can.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/lib/api/schema.ts
Comment on lines +44 to +51
export function isApiErrorPayload(value: unknown): value is ApiErrorPayload {
if (typeof value !== 'object' || value === null) return false;
const candidate = (value as { error?: unknown }).error;
return (
typeof candidate === 'object' &&
candidate !== null &&
typeof (candidate as { message?: unknown }).message === 'string'
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Api error validator incomplete 🐞 Bug ☼ Reliability

isApiErrorPayload() only checks error.message, but the client then trusts error.code and stores
it in state. Malformed/non-conforming responses can therefore yield undefined or unknown error codes
and degrade code-based error handling.
Agent Prompt
### Issue description
`isApiErrorPayload()` is used as a type guard but it doesn’t validate that `error.code` exists or is a member of the `ErrorCode` taxonomy. Downstream, `useRepoStructure` immediately trusts `body.error.code`, which can lead to invalid runtime state when the server/proxy returns a non-conforming payload.

### Issue Context
This is an HTTP boundary; even if the server normally returns the correct shape, clients should defensively validate all fields they rely on.

### Fix Focus Areas
- src/lib/api/schema.ts[44-51]
- src/hooks/useRepoStructure.ts[60-68]
- src/lib/errors.ts[7-23]

### Suggested fix
- Strengthen `isApiErrorPayload` to also validate:
  - `typeof error.code === 'string'` and it is included in `ERROR_CODES`.
  - (Optional) validate `hint` is string when present and `retryAfter` is number when present.
- Alternatively, define a zod schema for the error payload and use `safeParse`.
- In `readError`, if validation fails, fall back to the generic `UPSTREAM` error instead of trusting `code`.
- Add a unit test where the server returns `{ error: { message: 'x' } }` (missing `code`) to ensure the client falls back safely.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (13)
src/lib/github/__tests__/client.test.ts (1)

49-53: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Unrestored globalThis.fetch in both new test suites. Both suites assign a Jest mock to the global fetch in beforeEach and never restore the original, so the mock leaks to any later module in the same Jest worker.

  • src/lib/github/__tests__/client.test.ts#L49-L53: capture globalThis.fetch in module scope and restore it in an afterAll hook.
  • src/app/api/structure/__tests__/route.test.ts#L41-L44: apply the same capture-and-restore pattern.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/github/__tests__/client.test.ts` around lines 49 - 53, Restore the
original global fetch after each test suite to prevent mock leakage: in
src/lib/github/__tests__/client.test.ts lines 49-53, capture globalThis.fetch at
module scope and restore it in afterAll; apply the same capture-and-restore
pattern in src/app/api/structure/__tests__/route.test.ts lines 41-44.
src/app/api/structure/route.ts (1)

30-52: 📐 Maintainability & Code Quality | 🔵 Trivial

Consider a per-caller quota for this endpoint.

The handler spends the server's GitHub quota on every uncached request, and resolveStructure can issue up to five upstream calls for one request through ref widening. One caller can exhaust the shared token quota for all visitors. Add rate limiting at the edge or in middleware, keyed by IP.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/structure/route.ts` around lines 30 - 52, Add IP-keyed rate
limiting before the GET handler performs validation or calls resolveStructure,
preferably through the endpoint’s edge layer or middleware. Ensure rejected
callers receive the project’s standard rate-limit response, while allowed
requests retain the existing structureQuerySchema and resolveStructure flow.
src/lib/github/parse-repo-url.ts (1)

29-31: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Consider trimming instead of stripping all whitespace.

Line 30 removes every whitespace character, including internal ones. The input owner/re po becomes owner/repo, so the parser accepts a repository the user did not request. Strip only surrounding whitespace and reject the rest.

♻️ Proposed change
-  let value = raw.trim().replace(/\s+/g, '');
+  let value = raw.trim();
   if (!value) throw invalid('Paste a repository URL such as https://github.com/owner/repo.');
+  if (/\s/.test(value)) throw invalid('Remove the spaces from the repository URL.');

Note: the OpenGrep command-injection.exec-js hint on line 35 is a false positive. exec there is RegExp.prototype.exec.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/github/parse-repo-url.ts` around lines 29 - 31, Update normaliseInput
to trim only leading and trailing whitespace, removing the replace(/\s+/g, '')
behavior. Validate and reject any remaining internal whitespace instead of
silently altering repository names, while preserving the existing empty-input
error.

Source: Linters/SAST tools

src/lib/github/client.ts (1)

72-78: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a default timeout to GitHub requests.

This fetch has no deadline when the caller does not provide signal, so a stalled GitHub connection can keep the route handler open until the platform kills it. Add a fallback AbortSignal.timeout() and combine it with the caller signal using AbortSignal.any().

The proposed ABORTED code maps to status 499 in src/lib/errors.ts; add a separate TimeoutError mapping if a distinct upstream time-out response is needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/github/client.ts` around lines 72 - 78, Update the fetch request in
the GitHub client’s try block to always enforce a default timeout by creating a
fallback AbortSignal.timeout() and combining it with options.signal via
AbortSignal.any(). Preserve caller cancellation while ensuring requests without
a signal still expire, and add a distinct TimeoutError mapping in the existing
error handling if timeout responses must differ from the ABORTED/499 mapping.
src/lib/config.ts (1)

11-20: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Normalize the explicit site URL scheme.

getSiteUrl returns NEXT_PUBLIC_SITE_URL verbatim. If the variable holds a host without a scheme, such as fostgen.app, sitemap.ts and robots.ts emit non-absolute URLs. Add a scheme when it is missing.

♻️ Proposed normalization
 export function getSiteUrl(): string {
   const explicit = process.env.NEXT_PUBLIC_SITE_URL?.trim();
-  if (explicit) return explicit.replace(/\/$/, '');
+  if (explicit) {
+    const withScheme = /^https?:\/\//i.test(explicit) ? explicit : `https://${explicit}`;
+    return withScheme.replace(/\/$/, '');
+  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/config.ts` around lines 11 - 20, Update getSiteUrl to normalize
NEXT_PUBLIC_SITE_URL before returning it: preserve http:// or https:// values,
but prepend https:// when the trimmed explicit value lacks a scheme. Keep the
existing trailing-slash removal and Vercel/localhost fallback behavior
unchanged.
src/lib/format/units.ts (1)

23-29: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Reuse the Intl formatters at module scope.

Both formatters use fixed locale/options and are called on UI render paths (formatCount in StatsRow; formatCount and formatRelativeTime in RepositorySummary). Hoisting one Intl.NumberFormat and one Intl.RelativeTimeFormat instance avoids repeated formatter construction.
[provide_code_example]

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/format/units.ts` around lines 23 - 29, Hoist the fixed-locale
formatter instances to module scope in units.ts: create one Intl.NumberFormat
for formatCount and one Intl.RelativeTimeFormat for formatRelativeTime, then
reuse them inside those functions instead of constructing formatters on each
call. Preserve the existing locale, options, validation, and output behavior.
src/components/layout/Header.tsx (1)

23-25: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: hide the decorative shortcut hint from assistive technology.

StructureGenerator accepts both Meta+K and Ctrl+K, but this hint always shows ⌘K. Screen readers also announce the bare glyph without context. Mark the element decorative, and consider a platform-aware label.

♿ Proposed change
-          <kbd className="hidden rounded-md border border-line bg-elevated px-1.5 py-0.5 font-mono text-[0.6875rem] text-ink-subtle sm:inline-block">
+          <kbd
+            aria-hidden
+            className="hidden rounded-md border border-line bg-elevated px-1.5 py-0.5 font-mono text-[0.6875rem] text-ink-subtle sm:inline-block"
+          >
             ⌘K
           </kbd>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/layout/Header.tsx` around lines 23 - 25, Update the shortcut
hint kbd element in Header to be hidden from assistive technology because it is
decorative; preserve its visual ⌘K display, and do not add a misleading
accessibility label unless implementing platform-aware shortcut text.
src/components/ui/Toaster.tsx (1)

75-93: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: clear timers for toasts dropped by the visibility cap.

slice(-MAX_VISIBLE) removes the oldest toasts from state, but their entries stay in timers.current until each timeout fires. The later dismiss call is then a no-op. The map is bounded and self-heals, so this is hygiene only.

♻️ Proposed change
       setToasts((current) => {
         const next: Toast = {
           id,
           tone,
           title: normalised.title,
           ...(normalised.description ? { description: normalised.description } : {}),
         };
-        return [...current, next].slice(-MAX_VISIBLE);
+        const merged = [...current, next];
+        const visible = merged.slice(-MAX_VISIBLE);
+        for (const dropped of merged.slice(0, merged.length - visible.length)) {
+          const timer = timers.current.get(dropped.id);
+          if (timer) {
+            clearTimeout(timer);
+            timers.current.delete(dropped.id);
+          }
+        }
+        return visible;
       });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ui/Toaster.tsx` around lines 75 - 93, Optionally update the
toast state update around setToasts and timers.current so IDs removed by the
MAX_VISIBLE slice have their pending timer entries cleared. Preserve the
existing visibility cap and dismissal behavior, and only remove timer entries
for toasts dropped from state.
src/components/generator/OutputPanel.tsx (1)

35-47: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Count lines without allocating an array.

output.split('\n') builds a full array for large trees only to read length. A trailing newline also adds one phantom line to the displayed count. Count separators directly and ignore a single trailing newline.

♻️ Proposed line-count refactor
-  const lineCount = useMemo(() => (output ? output.split('\n').length : 0), [output]);
+  const lineCount = useMemo(() => {
+    if (!output) return 0;
+    const end = output.endsWith('\n') ? output.length - 1 : output.length;
+    let count = 1;
+    for (let index = 0; index < end; index += 1) {
+      if (output.charCodeAt(index) === 10) count += 1;
+    }
+    return count;
+  }, [output]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/generator/OutputPanel.tsx` around lines 35 - 47, Update the
lineCount calculation in OutputPanel to count newline separators directly
instead of calling output.split('\n'), and exclude one trailing newline from the
displayed count. Preserve the zero count for empty output and the existing
line-count display behavior.
src/components/generator/StructureGenerator.tsx (1)

70-73: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Defer re-derivation so option changes stay responsive.

deriveStructure runs synchronously in render. The depth range input in OptionsPanel fires onChange for every step of a drag, so a large tree is rebuilt, filtered, sorted, and rendered on each intermediate value. Wrap the inputs in useDeferredValue to keep the controls interactive.

♻️ Proposed deferred derivation
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react';
-  const derived = useMemo(
-    () => (payload ? deriveStructure(payload.nodes, options.value, rootName) : null),
-    [payload, options.value, rootName],
-  );
+  const deferredOptions = useDeferredValue(options.value);
+  const derived = useMemo(
+    () => (payload ? deriveStructure(payload.nodes, deferredOptions, rootName) : null),
+    [payload, deferredOptions, rootName],
+  );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/generator/StructureGenerator.tsx` around lines 70 - 73, Update
the derivation flow around deriveStructure in StructureGenerator so the options
input is passed through useDeferredValue before being used for memoized
computation. Use the deferred options for deriveStructure and its dependency
tracking, while keeping the current payload and rootName behavior unchanged.
src/components/theme/ThemeProvider.tsx (1)

86-99: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Tear down the shared listeners only when the last subscriber leaves.

Each cleanup removes the media-query and storage handlers unconditionally. With two subscribers, the first unsubscribe stops updates for the one that remains. Also drop the cached snapshot when the Set empties, so a value written while unsubscribed is not served stale. subscribeToKey in src/hooks/useLocalStorage.ts already applies this guard.

♻️ Proposed subscription lifecycle
   return () => {
     listeners.delete(listener);
+    if (listeners.size > 0) return;
     query?.removeEventListener('change', invalidate);
     window.removeEventListener('storage', invalidate);
+    snapshot = null;
   };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/theme/ThemeProvider.tsx` around lines 86 - 99, Update
subscribe in ThemeProvider so its cleanup removes the listener from listeners
first, then tears down the shared matchMedia and storage handlers only when
listeners becomes empty. At that point also clear the cached snapshot,
preserving shared subscriptions and ensuring the next subscription reads a fresh
value.
src/hooks/useClipboard.ts (1)

12-28: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Harden the legacy copy fallback.

Use focus({ preventScroll: true }) and setSelectionRange(0, text.length) before execCommand('copy'). On iOS Safari, an unfocused readonly textarea may not create a valid selection, so the fallback can copy nothing and still return true.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/useClipboard.ts` around lines 12 - 28, Update legacyCopy to focus
the readonly textarea with preventScroll enabled and set its selection range
from 0 through text.length after selecting and before execCommand('copy'),
preserving the existing cleanup and return behavior.
src/components/ui/Button.tsx (1)

40-57: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider aria-disabled instead of native disabled while loading.

The button sets the native disabled attribute (Line 43) at the same time as aria-busy (Line 44). When the button that currently has focus (for example, right after a click) becomes natively disabled, most browsers remove it from the tab order and shift focus to <body>. This loses the keyboard/screen-reader user's position during the primary "Generate" action, and aria-busy on a disabled element has little effect because disabled elements are commonly excluded from the accessibility tree.

Use aria-disabled and block the click handler in the loading state instead, so focus stays on the button and aria-busy remains meaningful to assistive technology.

♻️ Proposed fix to keep focus during loading
 export function Button({
   variant = 'secondary',
   size = 'md',
   loading = false,
   icon,
   className,
   children,
   disabled,
   type = 'button',
+  onClick,
   ...rest
 }: ButtonProps) {
   return (
     <button
       type={type}
-      disabled={disabled || loading}
+      disabled={disabled}
+      aria-disabled={disabled || loading || undefined}
       aria-busy={loading || undefined}
+      onClick={(event) => {
+        if (loading) {
+          event.preventDefault();
+          return;
+        }
+        onClick?.(event);
+      }}
       className={cn(
         'inline-flex items-center justify-center rounded-xl font-medium transition-colors',
         'disabled:cursor-not-allowed disabled:opacity-55',
+        loading && 'cursor-not-allowed opacity-55',
         VARIANTS[variant],
         SIZES[size],
         className,
       )}
       {...rest}
     >
       {loading ? <Spinner label="Working" /> : icon}
       {children}
     </button>
   );
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ui/Button.tsx` around lines 40 - 57, Update the Button
component’s loading behavior to avoid applying native disabled while loading:
use aria-disabled to expose the state, preserve aria-busy, and prevent click
handling during loading. Keep the native disabled behavior for the explicit
disabled prop and retain the existing styling and button API.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/ci.yml:
- Around line 9-18: Update the workflow-level configuration in ci.yml to grant
only contents: read permissions, and set persist-credentials: false on both
actions/checkout@v4 steps, including the additional checkout step referenced by
the comment.

In `@src/app/api/structure/route.ts`:
- Around line 65-69: Update the success response in the structure route to use
shared `public` caching only when the response does not depend on
`GITHUB_TOKEN`; token-authenticated responses must not be marked public or
stored in shared caches. Preserve the existing cache durations for eligible
unauthenticated responses, and use an appropriate private/no-store policy for
authenticated responses.

In `@src/components/generator/OptionsPanel.tsx`:
- Around line 146-154: The ignore-pattern textarea in OptionsPanel must preserve
raw user input locally instead of deriving its value from
options.ignorePatterns.join('\n'). Add local text state initialized from the
current ignore patterns, update that state on each change while publishing
parsePatternList results, and synchronize or publish the parsed value on blur as
needed without removing whitespace, trailing separators, or empty rows during
typing.

In `@src/components/generator/RepoForm.tsx`:
- Around line 39-41: Update the advancedOpen state in RepoForm so it
synchronizes with changes to values.ref and values.path after mount, including
resets from StructureGenerator.handleReset. Add the appropriate effect keyed to
those fields while preserving user-controlled expansion between prop changes.

In `@src/components/generator/StructureGenerator.tsx`:
- Around line 156-166: Update the useEffect tracking lastReportedError so it
clears lastReportedError.current whenever state.status is not 'error', before
returning. Preserve the existing duplicate-error suppression while remaining in
the error state, and allow the same code/message to notify again after any
successful or other non-error state.

In `@src/components/ui/Switch.tsx`:
- Around line 17-63: Make the implementation match the Switch comment by making
the full row, including the label and description area, toggle through the
existing onCheckedChange behavior while preserving disabled behavior and
preventing duplicate toggles from button bubbling. Use the Switch component’s
wrapper and button handlers to ensure clicks on the visible row activate the
switch.

In `@src/hooks/useLocalStorage.ts`:
- Around line 115-121: Update setValue in useLocalStorage to resolve functional
updates from a fresh synchronous read via readRaw rather than the
render-captured value, so consecutive updates observe prior writes. Add and
reuse a shared decode helper alongside readRaw for consistent JSON parsing,
fallback handling, and optional validation in both the initial read path and
setValue.

In `@src/hooks/useRepoStructure.ts`:
- Around line 117-124: In the request flow around the success and failure
dispatches, verify that the request’s controller is still the current controller
before dispatching either result. Apply this identity check after readError and
response.json resolve, so superseded or reset requests return without
dispatching and cannot restore stale data.

In `@src/lib/__tests__/config.test.ts`:
- Around line 66-79: Update the expected array in the “keeps only non-empty
strings and caps the list” test to include all eight valid repository strings
from the input before applying MAX_RECENT_REPOSITORIES slicing, ensuring the
assertion remains independent of the current cap value.

In `@src/lib/api/schema.ts`:
- Around line 44-52: Update isApiErrorPayload to also validate candidate.code
against the existing ERROR_CODES collection, requiring a valid ErrorCode
alongside the string message before returning true; preserve the current false
result for non-object or null payloads.

In `@src/lib/github/client.ts`:
- Around line 29-39: Update readRateLimit to validate the raw x-ratelimit-limit,
x-ratelimit-remaining, and x-ratelimit-reset header values before converting
them, returning null when any value is null or empty; retain the finite-number
validation for malformed non-empty values. Add a client test exercising a 403
response with no rate-limit headers and verify it remains a plain 403 rather
than being classified as rate-limited.

In `@src/lib/tree/filter.ts`:
- Around line 16-49: Bound user-supplied glob complexity in globToRegExp before
constructing the RegExp: enforce a maximum pattern length and maximum number of
globstar (** or **/) segments, rejecting or safely handling patterns that exceed
either limit. Preserve existing matching behavior for patterns within the limits
and ensure filterTree cannot compile unbounded backtracking expressions from
ignorePatterns.

In `@src/lib/tree/pipeline.ts`:
- Around line 43-44: Update the size aggregation condition in the tree pipeline
around `withAggregatedSizes` so it also runs when `options.sort` is `size-desc`,
while retaining the existing `options.showSizes` behavior. Keep `renderTree` and
`computeStats` display gating unchanged so this only supplies sizes for sorting.

---

Nitpick comments:
In `@src/app/api/structure/route.ts`:
- Around line 30-52: Add IP-keyed rate limiting before the GET handler performs
validation or calls resolveStructure, preferably through the endpoint’s edge
layer or middleware. Ensure rejected callers receive the project’s standard
rate-limit response, while allowed requests retain the existing
structureQuerySchema and resolveStructure flow.

In `@src/components/generator/OutputPanel.tsx`:
- Around line 35-47: Update the lineCount calculation in OutputPanel to count
newline separators directly instead of calling output.split('\n'), and exclude
one trailing newline from the displayed count. Preserve the zero count for empty
output and the existing line-count display behavior.

In `@src/components/generator/StructureGenerator.tsx`:
- Around line 70-73: Update the derivation flow around deriveStructure in
StructureGenerator so the options input is passed through useDeferredValue
before being used for memoized computation. Use the deferred options for
deriveStructure and its dependency tracking, while keeping the current payload
and rootName behavior unchanged.

In `@src/components/layout/Header.tsx`:
- Around line 23-25: Update the shortcut hint kbd element in Header to be hidden
from assistive technology because it is decorative; preserve its visual ⌘K
display, and do not add a misleading accessibility label unless implementing
platform-aware shortcut text.

In `@src/components/theme/ThemeProvider.tsx`:
- Around line 86-99: Update subscribe in ThemeProvider so its cleanup removes
the listener from listeners first, then tears down the shared matchMedia and
storage handlers only when listeners becomes empty. At that point also clear the
cached snapshot, preserving shared subscriptions and ensuring the next
subscription reads a fresh value.

In `@src/components/ui/Button.tsx`:
- Around line 40-57: Update the Button component’s loading behavior to avoid
applying native disabled while loading: use aria-disabled to expose the state,
preserve aria-busy, and prevent click handling during loading. Keep the native
disabled behavior for the explicit disabled prop and retain the existing styling
and button API.

In `@src/components/ui/Toaster.tsx`:
- Around line 75-93: Optionally update the toast state update around setToasts
and timers.current so IDs removed by the MAX_VISIBLE slice have their pending
timer entries cleared. Preserve the existing visibility cap and dismissal
behavior, and only remove timer entries for toasts dropped from state.

In `@src/hooks/useClipboard.ts`:
- Around line 12-28: Update legacyCopy to focus the readonly textarea with
preventScroll enabled and set its selection range from 0 through text.length
after selecting and before execCommand('copy'), preserving the existing cleanup
and return behavior.

In `@src/lib/config.ts`:
- Around line 11-20: Update getSiteUrl to normalize NEXT_PUBLIC_SITE_URL before
returning it: preserve http:// or https:// values, but prepend https:// when the
trimmed explicit value lacks a scheme. Keep the existing trailing-slash removal
and Vercel/localhost fallback behavior unchanged.

In `@src/lib/format/units.ts`:
- Around line 23-29: Hoist the fixed-locale formatter instances to module scope
in units.ts: create one Intl.NumberFormat for formatCount and one
Intl.RelativeTimeFormat for formatRelativeTime, then reuse them inside those
functions instead of constructing formatters on each call. Preserve the existing
locale, options, validation, and output behavior.

In `@src/lib/github/__tests__/client.test.ts`:
- Around line 49-53: Restore the original global fetch after each test suite to
prevent mock leakage: in src/lib/github/__tests__/client.test.ts lines 49-53,
capture globalThis.fetch at module scope and restore it in afterAll; apply the
same capture-and-restore pattern in
src/app/api/structure/__tests__/route.test.ts lines 41-44.

In `@src/lib/github/client.ts`:
- Around line 72-78: Update the fetch request in the GitHub client’s try block
to always enforce a default timeout by creating a fallback AbortSignal.timeout()
and combining it with options.signal via AbortSignal.any(). Preserve caller
cancellation while ensuring requests without a signal still expire, and add a
distinct TimeoutError mapping in the existing error handling if timeout
responses must differ from the ABORTED/499 mapping.

In `@src/lib/github/parse-repo-url.ts`:
- Around line 29-31: Update normaliseInput to trim only leading and trailing
whitespace, removing the replace(/\s+/g, '') behavior. Validate and reject any
remaining internal whitespace instead of silently altering repository names,
while preserving the existing empty-input error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a6eb5e9b-498c-4a42-a5bc-e8696ee4e19a

📥 Commits

Reviewing files that changed from the base of the PR and between 484126a and 286fc4c.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (79)
  • .env.example
  • .github/workflows/ci.yml
  • .gitignore
  • README.md
  • eslint.config.mjs
  • jest.config.js
  • jest.setup.js
  • jest.setup.ts
  • next.config.ts
  • package.json
  • src/app/api/structure/__tests__/route.test.ts
  • src/app/api/structure/route.ts
  • src/app/error.tsx
  • src/app/globals.css
  • src/app/layout.tsx
  • src/app/manifest.ts
  • src/app/not-found.tsx
  • src/app/page.tsx
  • src/app/robots.ts
  • src/app/sitemap.ts
  • src/components/Header.tsx
  • src/components/InputForm.tsx
  • src/components/Notification.tsx
  • src/components/OutputDisplay.tsx
  • src/components/__tests__/InputForm.test.tsx
  • src/components/generator/ErrorNotice.tsx
  • src/components/generator/OptionsPanel.tsx
  • src/components/generator/OutputPanel.tsx
  • src/components/generator/RecentRepositories.tsx
  • src/components/generator/RepoForm.tsx
  • src/components/generator/RepositorySummary.tsx
  • src/components/generator/StructureGenerator.tsx
  • src/components/generator/__tests__/RepoForm.test.tsx
  • src/components/generator/__tests__/StructureGenerator.test.tsx
  • src/components/icons/GithubMark.tsx
  • src/components/layout/Footer.tsx
  • src/components/layout/Header.tsx
  • src/components/theme/ThemeProvider.tsx
  • src/components/theme/ThemeToggle.tsx
  • src/components/ui/Badge.tsx
  • src/components/ui/Button.tsx
  • src/components/ui/Select.tsx
  • src/components/ui/Spinner.tsx
  • src/components/ui/Switch.tsx
  • src/components/ui/Toaster.tsx
  • src/components/ui/__tests__/Toaster.test.tsx
  • src/hooks/useClipboard.ts
  • src/hooks/useLocalStorage.ts
  • src/hooks/useRepoStructure.ts
  • src/lib/__tests__/config.test.ts
  • src/lib/api/schema.ts
  • src/lib/config.ts
  • src/lib/errors.ts
  • src/lib/format/__tests__/units.test.ts
  • src/lib/format/units.ts
  • src/lib/github/__tests__/client.test.ts
  • src/lib/github/__tests__/parse-repo-url.test.ts
  • src/lib/github/client.ts
  • src/lib/github/parse-repo-url.ts
  • src/lib/github/types.ts
  • src/lib/logger.ts
  • src/lib/tree/__tests__/build.test.ts
  • src/lib/tree/__tests__/filter.test.ts
  • src/lib/tree/__tests__/pipeline.test.ts
  • src/lib/tree/__tests__/render.test.ts
  • src/lib/tree/__tests__/sort.test.ts
  • src/lib/tree/build.ts
  • src/lib/tree/filter.ts
  • src/lib/tree/pipeline.ts
  • src/lib/tree/render.ts
  • src/lib/tree/sort.ts
  • src/lib/tree/stats.ts
  • src/lib/tree/types.ts
  • src/lib/utils/cn.ts
  • src/lib/utils/download.ts
  • src/utils/githubApi.ts
  • src/utils/logger.ts
  • tsconfig.json
  • turbo.json
💤 Files with no reviewable changes (9)
  • src/components/Header.tsx
  • src/utils/logger.ts
  • src/components/InputForm.tsx
  • src/components/Notification.tsx
  • src/components/OutputDisplay.tsx
  • jest.setup.js
  • turbo.json
  • src/utils/githubApi.ts
  • src/components/tests/InputForm.test.tsx

Comment thread .github/workflows/ci.yml
Comment on lines +9 to +18
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
verify:
name: Lint, typecheck, test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Set minimum workflow permissions and disable persisted checkout credentials.

The workflow inherits the repository default GITHUB_TOKEN permissions. Both actions/checkout steps retain the token in local Git configuration. Define permissions: contents: read at workflow scope. Set persist-credentials: false for both checkout steps.

Proposed fix
 on:
   push:
     branches: [main]
   pull_request:
   workflow_dispatch:

+permissions:
+  contents: read
+
 concurrency:
   group: ci-${{ github.ref }}
   cancel-in-progress: true
@@
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v4
+        with:
+          persist-credentials: false
@@
-      - uses: actions/checkout@v4
+      - uses: actions/checkout@v4
+        with:
+          persist-credentials: false

Also applies to: 48-48

🧰 Tools
🪛 zizmor (1.28.0)

[warning] 18-18: credential persistence through GitHub Actions artifacts (artipacked): does not set persist-credentials: false

(artipacked)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 9 - 18, Update the workflow-level
configuration in ci.yml to grant only contents: read permissions, and set
persist-credentials: false on both actions/checkout@v4 steps, including the
additional checkout step referenced by the comment.

Source: Linters/SAST tools

Comment on lines +65 to +69
return NextResponse.json(payload, {
headers: {
'Cache-Control': `public, s-maxage=${GITHUB_CACHE_TTL_SECONDS}, stale-while-revalidate=${GITHUB_CACHE_TTL_SECONDS * 2}`,
},
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not mark token-authenticated responses as public.

Every success response is cached with public, s-maxage=.... When GITHUB_TOKEN grants access to private repositories, the private repository structure is stored in shared and intermediary caches. Restrict shared caching to responses that do not depend on the token.

🛡️ Proposed change
+    const shared = !process.env.GITHUB_TOKEN?.trim();
+
     return NextResponse.json(payload, {
       headers: {
-        'Cache-Control': `public, s-maxage=${GITHUB_CACHE_TTL_SECONDS}, stale-while-revalidate=${GITHUB_CACHE_TTL_SECONDS * 2}`,
+        'Cache-Control': shared
+          ? `public, s-maxage=${GITHUB_CACHE_TTL_SECONDS}, stale-while-revalidate=${GITHUB_CACHE_TTL_SECONDS * 2}`
+          : `private, max-age=${GITHUB_CACHE_TTL_SECONDS}`,
       },
     });

If the deployment only ever targets public repositories, keep the current header and document that constraint in .env.example.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return NextResponse.json(payload, {
headers: {
'Cache-Control': `public, s-maxage=${GITHUB_CACHE_TTL_SECONDS}, stale-while-revalidate=${GITHUB_CACHE_TTL_SECONDS * 2}`,
},
});
const shared = !process.env.GITHUB_TOKEN?.trim();
return NextResponse.json(payload, {
headers: {
'Cache-Control': shared
? `public, s-maxage=${GITHUB_CACHE_TTL_SECONDS}, stale-while-revalidate=${GITHUB_CACHE_TTL_SECONDS * 2}`
: `private, max-age=${GITHUB_CACHE_TTL_SECONDS}`,
},
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/structure/route.ts` around lines 65 - 69, Update the success
response in the structure route to use shared `public` caching only when the
response does not depend on `GITHUB_TOKEN`; token-authenticated responses must
not be marked public or stored in shared caches. Preserve the existing cache
durations for eligible unauthenticated responses, and use an appropriate
private/no-store policy for authenticated responses.

Comment on lines +146 to +154
<textarea
id={ignoreId}
rows={2}
spellCheck={false}
placeholder="*.test.ts, docs/**, !docs/api"
value={options.ignorePatterns.join('\n')}
onChange={(event) => onChange({ ignorePatterns: parsePatternList(event.target.value) })}
className="resize-y rounded-xl border border-line bg-surface px-3 py-2 font-mono text-xs text-ink placeholder:font-sans placeholder:text-ink-subtle hover:border-line-strong"
/>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect parsePatternList to confirm whether it drops empty or untrimmed segments.
fd -t f 'filter.ts' src/lib/tree --exec rg -n -A 20 'export function parsePatternList'

Repository: idugeni/fostgen

Length of output: 866


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the OptionsPanel component around the textarea implementation.
printf 'Files matching OptionsPanel.tsx:\n'
fd -t f '^OptionsPanel\.tsx$' src

printf '\nOptionsPanel outline:\n'
ast-grep outline src/components/generator/OptionsPanel.tsx --view expanded || true

printf '\nRelevant OptionsPanel lines:\n'
sed -n '1,240p' src/components/generator/OptionsPanel.tsx | cat -n

Repository: idugeni/fostgen

Length of output: 8133


🏁 Script executed:

#!/bin/bash
set -euo pipefail

node - <<'JS'
function parsePatternList(input) {
  return input
    .split(/[\n,]/)
    .map((value) => value.trim())
    .filter(Boolean);
}

function roundtrip(source) {
  const before = source.split('\n');
  const parsed = parsePatternList(source);
  const rendered = parsed.join('\n');
  return {
    before,
    parsed,
    rendered,
    identical: source === rendered
  };
}

for (const [name, source] of [
  ['currently supports two lines', '*.test.ts\ndocs/**'],
  ['trailing newline at end', '*.test.ts\ndocs/**\n'],
  ['empty line between patterns', '*.test.ts\ndocs/**\n!important.ts'],
  ['leading space before second pattern', '*.test.ts\n docs/**'],
  ['comma with trailing space', '*.test.ts, docs/**'],
  ['comma-separated empty segment', '*.test.ts,\n,docs/**'],
]) {
  console.log(JSON.stringify({ name, ...roundtrip(source) }, null, 2));
}
JS

Repository: idugeni/fostgen

Length of output: 1430


Keep the ignore-pattern text in local textarea state.

parsePatternList() trims entries and filters empty segments, then value={options.ignorePatterns.join('\n')} rewrites user input after each keystroke. This makes trailing newlines, commas, leading/trailing whitespace, and empty rows disappear from the textarea. Store the raw text locally and publish the parsed list only when the parsed value changes or on blur.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/generator/OptionsPanel.tsx` around lines 146 - 154, The
ignore-pattern textarea in OptionsPanel must preserve raw user input locally
instead of deriving its value from options.ignorePatterns.join('\n'). Add local
text state initialized from the current ignore patterns, update that state on
each change while publishing parsePatternList results, and synchronize or
publish the parsed value on blur as needed without removing whitespace, trailing
separators, or empty rows during typing.

Comment on lines +39 to +41
const [advancedOpen, setAdvancedOpen] = useState(
() => values.ref.length > 0 || values.path.length > 0,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Sync advancedOpen with prop changes, not just the initial mount.

advancedOpen is computed once from values.ref and values.path through the lazy useState initializer. This value never updates after mount, so it does not track values changes coming from the parent.

StructureGenerator (see src/components/generator/StructureGenerator.tsx, handleReset) resets form to EMPTY_FORM but does not remount RepoForm. If a user expands "Branch & sub-directory", enters a value, then clicks "Clear", the section stays expanded even though values.ref and values.path are now empty. This is a stale-state glitch, not a functional blocker.

♻️ Proposed fix using an effect to resync on prop change
-  const [advancedOpen, setAdvancedOpen] = useState(
-    () => values.ref.length > 0 || values.path.length > 0,
-  );
+  const [advancedOpen, setAdvancedOpen] = useState(
+    () => values.ref.length > 0 || values.path.length > 0,
+  );
+
+  useEffect(() => {
+    if (values.ref.length === 0 && values.path.length === 0) {
+      setAdvancedOpen(false);
+    }
+  }, [values.ref, values.path]);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [advancedOpen, setAdvancedOpen] = useState(
() => values.ref.length > 0 || values.path.length > 0,
);
const [advancedOpen, setAdvancedOpen] = useState(
() => values.ref.length > 0 || values.path.length > 0,
);
useEffect(() => {
if (values.ref.length === 0 && values.path.length === 0) {
setAdvancedOpen(false);
}
}, [values.ref, values.path]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/generator/RepoForm.tsx` around lines 39 - 41, Update the
advancedOpen state in RepoForm so it synchronizes with changes to values.ref and
values.path after mount, including resets from StructureGenerator.handleReset.
Add the appropriate effect keyed to those fields while preserving
user-controlled expansion between prop changes.

Comment on lines +156 to +166
const lastReportedError = useRef<string | null>(null);
useEffect(() => {
if (state.status !== 'error' || !state.error) return;
const signature = `${state.error.code}:${state.error.message}`;
if (lastReportedError.current === signature) return;
lastReportedError.current = signature;
notify.error({
title: state.error.message,
...(state.error.hint ? { description: state.error.hint } : {}),
});
}, [state.status, state.error, notify]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Repeated identical errors produce no toast.

lastReportedError holds the last code:message signature and is cleared only in handleReset. If a request fails, then succeeds, then fails again with the same code and message, the second failure emits no toast. Clear the signature when the status leaves error.

🐛 Proposed fix: clear the signature on non-error states
   useEffect(() => {
-    if (state.status !== 'error' || !state.error) return;
+    if (state.status !== 'error' || !state.error) {
+      lastReportedError.current = null;
+      return;
+    }
     const signature = `${state.error.code}:${state.error.message}`;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const lastReportedError = useRef<string | null>(null);
useEffect(() => {
if (state.status !== 'error' || !state.error) return;
const signature = `${state.error.code}:${state.error.message}`;
if (lastReportedError.current === signature) return;
lastReportedError.current = signature;
notify.error({
title: state.error.message,
...(state.error.hint ? { description: state.error.hint } : {}),
});
}, [state.status, state.error, notify]);
const lastReportedError = useRef<string | null>(null);
useEffect(() => {
if (state.status !== 'error' || !state.error) {
lastReportedError.current = null;
return;
}
const signature = `${state.error.code}:${state.error.message}`;
if (lastReportedError.current === signature) return;
lastReportedError.current = signature;
notify.error({
title: state.error.message,
...(state.error.hint ? { description: state.error.hint } : {}),
});
}, [state.status, state.error, notify]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/generator/StructureGenerator.tsx` around lines 156 - 166,
Update the useEffect tracking lastReportedError so it clears
lastReportedError.current whenever state.status is not 'error', before
returning. Preserve the existing duplicate-error suppression while remaining in
the error state, and allow the same code/message to notify again after any
successful or other non-error state.

Comment on lines +66 to +79
it('keeps only non-empty strings and caps the list', () => {
const input = ['a/b', '', 3, null, 'c/d', 'e/f', 'g/h', 'i/j', 'k/l', 'm/n', 'o/p'];
const result = parseRecentRepositories(input);

expect(result).toEqual([
'a/b',
'c/d',
'e/f',
'g/h',
'i/j',
'k/l',
].slice(0, MAX_RECENT_REPOSITORIES));
expect(result?.length).toBeLessThanOrEqual(MAX_RECENT_REPOSITORIES);
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'MAX_RECENT_REPOSITORIES' src/lib/config.ts

Repository: idugeni/fostgen

Length of output: 242


Fix the truncated expected list in this test.

The input contains 8 valid repository strings after filtering, but the expected array only lists 6 before applying .slice(0, MAX_RECENT_REPOSITORIES). Keep the expected value in sync with the parsed results so the test does not only pass due to the current MAX_RECENT_REPOSITORIES value being 6.

🔧 Proposed fix
   it('keeps only non-empty strings and caps the list', () => {
     const input = ['a/b', '', 3, null, 'c/d', 'e/f', 'g/h', 'i/j', 'k/l', 'm/n', 'o/p'];
     const result = parseRecentRepositories(input);
 
-    expect(result).toEqual([
-      'a/b',
-      'c/d',
-      'e/f',
-      'g/h',
-      'i/j',
-      'k/l',
-    ].slice(0, MAX_RECENT_REPOSITORIES));
+    expect(result).toEqual(
+      ['a/b', 'c/d', 'e/f', 'g/h', 'i/j', 'k/l', 'm/n', 'o/p'].slice(0, MAX_RECENT_REPOSITORIES),
+    );
     expect(result?.length).toBeLessThanOrEqual(MAX_RECENT_REPOSITORIES);
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('keeps only non-empty strings and caps the list', () => {
const input = ['a/b', '', 3, null, 'c/d', 'e/f', 'g/h', 'i/j', 'k/l', 'm/n', 'o/p'];
const result = parseRecentRepositories(input);
expect(result).toEqual([
'a/b',
'c/d',
'e/f',
'g/h',
'i/j',
'k/l',
].slice(0, MAX_RECENT_REPOSITORIES));
expect(result?.length).toBeLessThanOrEqual(MAX_RECENT_REPOSITORIES);
});
it('keeps only non-empty strings and caps the list', () => {
const input = ['a/b', '', 3, null, 'c/d', 'e/f', 'g/h', 'i/j', 'k/l', 'm/n', 'o/p'];
const result = parseRecentRepositories(input);
expect(result).toEqual(
['a/b', 'c/d', 'e/f', 'g/h', 'i/j', 'k/l', 'm/n', 'o/p'].slice(0, MAX_RECENT_REPOSITORIES),
);
expect(result?.length).toBeLessThanOrEqual(MAX_RECENT_REPOSITORIES);
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/__tests__/config.test.ts` around lines 66 - 79, Update the expected
array in the “keeps only non-empty strings and caps the list” test to include
all eight valid repository strings from the input before applying
MAX_RECENT_REPOSITORIES slicing, ensuring the assertion remains independent of
the current cap value.

Comment thread src/lib/api/schema.ts
Comment on lines +44 to +52
export function isApiErrorPayload(value: unknown): value is ApiErrorPayload {
if (typeof value !== 'object' || value === null) return false;
const candidate = (value as { error?: unknown }).error;
return (
typeof candidate === 'object' &&
candidate !== null &&
typeof (candidate as { message?: unknown }).message === 'string'
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Validate error.code in the guard.

The guard only checks message. A payload such as { error: { message: 'x' } } passes, so the consumer in src/hooks/useRepoStructure.ts assigns undefined to StructureError.code, which the type declares as ErrorCode. Any UI branching on the code then fails silently. Validate the code against ERROR_CODES.

🛡️ Proposed fix
-import { type ErrorCode } from '`@/lib/errors`';
+import { ERROR_CODES, type ErrorCode } from '`@/lib/errors`';
 export function isApiErrorPayload(value: unknown): value is ApiErrorPayload {
   if (typeof value !== 'object' || value === null) return false;
   const candidate = (value as { error?: unknown }).error;
-  return (
-    typeof candidate === 'object' &&
-    candidate !== null &&
-    typeof (candidate as { message?: unknown }).message === 'string'
-  );
+  if (typeof candidate !== 'object' || candidate === null) return false;
+  const { code, message } = candidate as { code?: unknown; message?: unknown };
+  return (
+    typeof message === 'string' &&
+    typeof code === 'string' &&
+    (ERROR_CODES as readonly string[]).includes(code)
+  );
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function isApiErrorPayload(value: unknown): value is ApiErrorPayload {
if (typeof value !== 'object' || value === null) return false;
const candidate = (value as { error?: unknown }).error;
return (
typeof candidate === 'object' &&
candidate !== null &&
typeof (candidate as { message?: unknown }).message === 'string'
);
}
import { ERROR_CODES, type ErrorCode } from '`@/lib/errors`';
export function isApiErrorPayload(value: unknown): value is ApiErrorPayload {
if (typeof value !== 'object' || value === null) return false;
const candidate = (value as { error?: unknown }).error;
if (typeof candidate !== 'object' || candidate === null) return false;
const { code, message } = candidate as { code?: unknown; message?: unknown };
return (
typeof message === 'string' &&
typeof code === 'string' &&
(ERROR_CODES as readonly string[]).includes(code)
);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/api/schema.ts` around lines 44 - 52, Update isApiErrorPayload to also
validate candidate.code against the existing ERROR_CODES collection, requiring a
valid ErrorCode alongside the string message before returning true; preserve the
current false result for non-object or null payloads.

Comment thread src/lib/github/client.ts
Comment on lines +29 to +39
function readRateLimit(headers: Headers, authenticated: boolean): RateLimitInfo | null {
const limit = Number(headers.get('x-ratelimit-limit'));
const remaining = Number(headers.get('x-ratelimit-remaining'));
const reset = Number(headers.get('x-ratelimit-reset'));

if (!Number.isFinite(limit) || !Number.isFinite(remaining) || !Number.isFinite(reset)) {
return null;
}

return { limit, remaining, reset, authenticated };
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔴 Critical | ⚡ Quick win

Fix the absent-header check: Number(null) is 0, not NaN.

headers.get() returns null when a header is absent, and Number(null) is 0, which passes Number.isFinite. So when GitHub omits the rate-limit headers, this function returns { limit: 0, remaining: 0, reset: 0 } instead of null. Three consequences follow:

  • Line 100: response.status === 403 && rateLimit?.remaining === 0 becomes true for every 403 that carries no rate-limit headers. A private-repository 403 is reported as RATE_LIMITED with status 429, so the user never receives the GITHUB_TOKEN hint from line 105.
  • Lines 42-47: reset of 0 produces retryAfter: 0 and the message "Try again in about 0 min". src/app/api/structure/route.ts line 16 then sends Retry-After: 0.
  • StructurePayload.rateLimit reports a fabricated 0/0 quota to the client.

Number('') is also 0, so an empty header value behaves the same way. Read the header value first and reject null or empty strings.

🐛 Proposed fix
+function readHeaderNumber(headers: Headers, name: string): number | null {
+  const raw = headers.get(name);
+  if (raw === null || raw.trim() === '') return null;
+  const value = Number(raw);
+  return Number.isFinite(value) ? value : null;
+}
+
 function readRateLimit(headers: Headers, authenticated: boolean): RateLimitInfo | null {
-  const limit = Number(headers.get('x-ratelimit-limit'));
-  const remaining = Number(headers.get('x-ratelimit-remaining'));
-  const reset = Number(headers.get('x-ratelimit-reset'));
-
-  if (!Number.isFinite(limit) || !Number.isFinite(remaining) || !Number.isFinite(reset)) {
-    return null;
-  }
+  const limit = readHeaderNumber(headers, 'x-ratelimit-limit');
+  const remaining = readHeaderNumber(headers, 'x-ratelimit-remaining');
+  const reset = readHeaderNumber(headers, 'x-ratelimit-reset');
+
+  if (limit === null || remaining === null || reset === null) {
+    return null;
+  }
 
   return { limit, remaining, reset, authenticated };
 }

The existing test "distinguishes a plain 403 from a rate limit" cannot detect this, because the jsonResponse helper in src/lib/github/__tests__/client.test.ts always injects rate-limit headers. Add a case with no rate-limit headers.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function readRateLimit(headers: Headers, authenticated: boolean): RateLimitInfo | null {
const limit = Number(headers.get('x-ratelimit-limit'));
const remaining = Number(headers.get('x-ratelimit-remaining'));
const reset = Number(headers.get('x-ratelimit-reset'));
if (!Number.isFinite(limit) || !Number.isFinite(remaining) || !Number.isFinite(reset)) {
return null;
}
return { limit, remaining, reset, authenticated };
}
function readHeaderNumber(headers: Headers, name: string): number | null {
const raw = headers.get(name);
if (raw === null || raw.trim() === '') return null;
const value = Number(raw);
return Number.isFinite(value) ? value : null;
}
function readRateLimit(headers: Headers, authenticated: boolean): RateLimitInfo | null {
const limit = readHeaderNumber(headers, 'x-ratelimit-limit');
const remaining = readHeaderNumber(headers, 'x-ratelimit-remaining');
const reset = readHeaderNumber(headers, 'x-ratelimit-reset');
if (limit === null || remaining === null || reset === null) {
return null;
}
return { limit, remaining, reset, authenticated };
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/github/client.ts` around lines 29 - 39, Update readRateLimit to
validate the raw x-ratelimit-limit, x-ratelimit-remaining, and x-ratelimit-reset
header values before converting them, returning null when any value is null or
empty; retain the finite-number validation for malformed non-empty values. Add a
client test exercising a 403 response with no rate-limit headers and verify it
remains a plain 403 rather than being classified as rate-limited.

Comment thread src/lib/tree/filter.ts
Comment on lines +16 to +49
function globToRegExp(glob: string): RegExp {
let source = '';
let index = 0;

while (index < glob.length) {
const char = glob[index] ?? '';

if (char === '*') {
const isGlobstar = glob[index + 1] === '*';

if (isGlobstar && glob[index + 2] === '/') {
// `**/foo` must also match a root-level `foo`, hence the optional group.
source += '(?:.*/)?';
index += 3;
continue;
}

source += isGlobstar ? '.*' : '[^/]*';
index += isGlobstar ? 2 : 1;
continue;
}

if (char === '?') {
source += '[^/]';
index += 1;
continue;
}

source += char.replace(REGEX_SPECIALS, '\\$&');
index += 1;
}

return new RegExp(`^${source}$`);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

[request_verification]

Repository: idugeni/fostgen

Length of output: 213


🌐 Web query:

Does the picomatch npm package guard against ReDoS in glob pattern compilation?

💡 Result:

The picomatch npm package has historically been vulnerable to Regular Expression Denial of Service (ReDoS) due to catastrophic backtracking when processing certain crafted extglob patterns [1][2]. Specifically, patterns using extglob quantifiers like + and *, particularly when nested or containing overlapping alternatives, could cause significant CPU consumption and block the Node.js event loop [1][3]. This vulnerability was addressed in versions 4.0.4, 3.0.2, and 2.3.2 [1][2]. Users are strongly advised to upgrade to one of these versions (or later) to mitigate the risk [2][4]. In addition to upgrading, the package provides configuration options that can help guard against such issues, particularly when dealing with untrusted input [5][6]: - maxExtglobRecursion: This option allows you to set a limit on nested quantified extglobs. If the limit is exceeded, the extglob is treated as a literal string instead of being compiled into a potentially dangerous regular expression [5][6]. - noextglob: This option can be set to true to completely disable support for extglobs [5][6]. If you are processing untrusted glob patterns, it is recommended to use these safeguards or implement other mitigations such as validating input patterns, enforcing strict allowlists, or running matching operations in isolated processes with resource limits [1][7].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | grep -E 'src/lib/tree/filter.ts|src/lib/__tests__/config.test.ts|GeneratorOptions|ignorePatterns' || true

echo
echo "== filter.ts =="
if [ -f src/lib/tree/filter.ts ]; then
  wc -l src/lib/tree/filter.ts
  sed -n '1,190p' src/lib/tree/filter.ts | cat -n
fi

echo
echo "== tests mentioning ignorePatterns/config =="
rg -n "ignorePatterns|GeneratorOptions|globToRegExp|filterTree" -S . --glob '!node_modules' --glob '!dist' --glob '!build' || true

echo
echo "== package files =="
git ls-files | grep -E '(^|/)(package.json|pnpm-lock.yaml|package-lock.json|yarn.lock)$' | sed -n '1,50p'
if [ -f package.json ]; then
  sed -n '1,220p' package.json | cat -n
fi

Repository: idugeni/fostgen

Length of output: 13388


🌐 Web query:

picomatch latest README options maxExtglobRecursion noextglob

💡 Result:

In picomatch, the options maxExtglobRecursion and noextglob control how extended glob patterns are processed [1][2]. maxExtglobRecursion This option is a number or boolean with a default value of 0 [1][2]. It serves as a safeguard against complex or potentially dangerous nested quantified extglobs (e.g., +(+(a))) [1][2]. If the recursion depth exceeds the specified limit, the extglob is treated as a literal string rather than being compiled into a regular expression [1][2]. To disable this safeguard, you can set the option to false [1][2]. noextglob This option is a boolean with a default value of false [1][2]. When set to true, it disables support for extended glob syntax (such as +(a|b)), causing those patterns to be treated as literal characters instead of special pattern syntax [1][3]. The option noext is also available as an alias for noextglob [1][2].

Citations:


🌐 Web query:

minimatch regular expression denial of service source option

💡 Result:

Minimatch has been subject to several Regular Expression Denial of Service (ReDoS) vulnerabilities due to its reliance on JavaScript regular expressions for glob matching [1][2][3]. These issues stem from the generation of complex, exponentially back-tracking regular expressions when processing specific types of untrusted input [4][5][6]. There is no single "source" option that acts as a universal defense against ReDoS in minimatch [1]. Instead, mitigation relies on updating to patched versions of the library and, in specific cases, using configuration options to disable vulnerable features [4][2][3]. Key Mitigation Strategies: 1. Update the Library: Several recent CVEs (e.g., CVE-2026-26996, CVE-2026-27904) have addressed these vulnerabilities. Patched versions (e.g., 10.2.1, 10.2.3 and various corresponding versions for older major releases) include fixes for the root causes of catastrophic backtracking [2][3][7][6]. 2. Disable Extglobs: For the vulnerability related to nested * extglobs, passing the option { noext: true } to the minimatch function can mitigate the risk by disabling extglob processing entirely [4]. 3. General Security Advice: The maintainer explicitly warns that any system taking user-supplied input and using it to generate regular expressions is inherently susceptible to ReDoS [1]. Developers are advised to: - Sanitize or validate user-provided glob patterns before passing them to minimatch [4][5]. - Avoid allowing users to influence patterns in high-risk contexts, such as CI/CD pipelines, build tools, or multi-tenant configuration systems [8][5][6]. The library's configuration interface does include options like maxExtglobRecursion and maxGlobstarRecursion, which are intended to limit resource consumption, though they are not a substitute for proper input sanitization [9][10]. The library maintainer has stated that because malicious patterns can always be crafted against a bash-compatible glob matcher using JavaScript regex, future ReDoS reports are considered "working as intended" and resolved by this standing warning [1].

Citations:


Bound glob pattern complexity before compiling ignore patterns.

globToRegExp turns repeated **/ segments into chained (?:.*/)? optional groups. A crafted pattern with many **/ segments followed by a non-matching literal can backtrack exponentially when filterTree tests it against generated node paths. Since ignorePatterns is editable by users, cap the number of ** segments per pattern, cap pattern length, or use a glob matcher with ReDoS-safe options.

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 47-47: Regular expression constructed from variable input detected. This can lead to Regular Expression Denial of Service (ReDoS) attacks if the variable contains malicious patterns. Use libraries like 'recheck' to validate regex safety or use static patterns.
Context: new RegExp(^${source}$)
Note: [CWE-1333] Inefficient Regular Expression Complexity

(regexp-from-variable)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/tree/filter.ts` around lines 16 - 49, Bound user-supplied glob
complexity in globToRegExp before constructing the RegExp: enforce a maximum
pattern length and maximum number of globstar (** or **/) segments, rejecting or
safely handling patterns that exceed either limit. Preserve existing matching
behavior for patterns within the limits and ensure filterTree cannot compile
unbounded backtracking expressions from ignorePatterns.

Source: Linters/SAST tools

Comment thread src/lib/tree/pipeline.ts
Comment on lines +43 to +44
const sized = options.showSizes ? withAggregatedSizes(filtered) : filtered;
const sorted = sortTree(sized, options.sort);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Aggregate sizes when the sort mode is size-desc.

Line 43 computes directory sizes only when options.showSizes is true. If a user selects size-desc with showSizes off, directories carry no size. compare in src/lib/tree/sort.ts line 24 then reads them as 0 and places every directory after every file, which contradicts the "Largest first" label. Aggregate sizes whenever the ordering depends on them.

🐛 Proposed fix
-  const sized = options.showSizes ? withAggregatedSizes(filtered) : filtered;
+  const needsSizes = options.showSizes || options.sort === 'size-desc';
+  const sized = needsSizes ? withAggregatedSizes(filtered) : filtered;
   const sorted = sortTree(sized, options.sort);

renderTree and computeStats still gate size display on options.showSizes, so the rendered output does not change.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const sized = options.showSizes ? withAggregatedSizes(filtered) : filtered;
const sorted = sortTree(sized, options.sort);
const needsSizes = options.showSizes || options.sort === 'size-desc';
const sized = needsSizes ? withAggregatedSizes(filtered) : filtered;
const sorted = sortTree(sized, options.sort);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/tree/pipeline.ts` around lines 43 - 44, Update the size aggregation
condition in the tree pipeline around `withAggregatedSizes` so it also runs when
`options.sort` is `size-desc`, while retaining the existing `options.showSizes`
behavior. Keep `renderTree` and `computeStats` display gating unchanged so this
only supplies sizes for sorting.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants