From 8197657dacbd57ba8eec83ce5787e54dbedb446b Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sun, 8 Feb 2026 19:42:19 +0000 Subject: [PATCH 1/8] fix: restore vault-backed sonar env and tailwind TS scanning --- .env | 58 ++++++ .gitignore | 1 - scripts/sonar-scan.js | 381 ++++++++++++++++++++++++++++++++-------- scripts/vault-helper.sh | 250 ++++++++++++++++++++++++++ tailwind.config.js | 2 +- 5 files changed, 617 insertions(+), 75 deletions(-) create mode 100644 .env create mode 100755 scripts/vault-helper.sh diff --git a/.env b/.env new file mode 100644 index 0000000..8158b11 --- /dev/null +++ b/.env @@ -0,0 +1,58 @@ +# This file is committed to git no secrets. + +#!/usr/bin/env bash + +# Vault environment loader wrapper +# Usage: source .env (never run directly) + +set -o pipefail + +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + echo "This script must be sourced: source .env" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HELPER_PATH="${VAULT_HELPER_PATH:-${SCRIPT_DIR}/scripts/vault-helper.sh}" + +if [[ ! -r "$HELPER_PATH" ]]; then + echo "Vault helper not found at $HELPER_PATH" >&2 + return 1 +fi + +# shellcheck source=./scripts/vault-helper.sh +source "$HELPER_PATH" + +DEFAULT_VAULT_SECRET_DEFS=$'kv/Sonarqube/sonarqube|SONAR_TOKEN=SONAR_TOKEN SONAR_TOKEN=sonar_token SONAR_TOKEN=token\nkv/dependencytrack|DTRACK_API_KEY=DTRACK_API_KEY DTRACK_API_KEY=api_key DTRACK_API_KEY=token' +DEFAULT_VAULT_REQUIRED_VARS="SONAR_TOKEN DTRACK_API_KEY" + +if [[ -z "${VAULT_TOKEN:-}" ]]; then + token_candidates=() + if [[ -n "${VAULT_TOKEN_FILE:-}" ]]; then + token_candidates+=("$VAULT_TOKEN_FILE") + fi + token_candidates+=("${HOME}/.vault-token" "/home/vscode/.vault-token" "/root/.vault-token") + for token_path in "${token_candidates[@]}"; do + if [[ -r "$token_path" ]]; then + VAULT_TOKEN_FILE="$token_path" + export VAULT_TOKEN_FILE + break + fi + done +fi + +SECRET_DEFS="${VAULT_SECRET_PATHS:-$DEFAULT_VAULT_SECRET_DEFS}" +REQUIRED_VARS="${VAULT_REQUIRED_VARS:-$DEFAULT_VAULT_REQUIRED_VARS}" + +vault_helper::load_from_definitions "$SECRET_DEFS" "$REQUIRED_VARS" "$VAULT_TOKEN_FILE" + +# Commented out for CI/automated testing +# SONAR_TOKEN="" +DTR_PROJECT_KEY= +# DTRACK_API_KEY="" +DTRACK_PROJECT=ai-code-fusion +DTRACK_PROJECT_VERSION=main + +# Local SonarQube defaults (cross-platform, no secrets) +SONAR_PROJECT_KEY=ai-code-fusion +# SONAR_TOKEN= diff --git a/.gitignore b/.gitignore index 9ea89c2..c393129 100755 --- a/.gitignore +++ b/.gitignore @@ -62,7 +62,6 @@ $RECYCLE.BIN/ ._* # Environment variables and local config -.env .env.local .env.* .scannerwork/ diff --git a/scripts/sonar-scan.js b/scripts/sonar-scan.js index 94f42dc..bf3ff31 100755 --- a/scripts/sonar-scan.js +++ b/scripts/sonar-scan.js @@ -1,48 +1,256 @@ #!/usr/bin/env node -const { execSync } = require('child_process'); +const { execSync, spawnSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const sonarqubeScanner = require('sonarqube-scanner'); -// Load environment variables from .env file -const dotenvPath = path.resolve(__dirname, '..', '.env'); -if (fs.existsSync(dotenvPath)) { - console.log('Loading environment variables from .env file'); - const envConfig = fs - .readFileSync(dotenvPath, 'utf8') - .split('\n') - .filter((line) => line.trim() && !line.startsWith('#')) - .reduce((acc, line) => { - const [key, value] = line.split('=').map((part) => part.trim()); - if (key && value) { - acc[key] = value; - // Also set in process.env if not already set - if (!process.env[key]) { - process.env[key] = value; - } - } - return acc; - }, {}); - - console.log('Loaded environment variables:', Object.keys(envConfig).join(', ')); +function parseEnvValue(rawValue) { + const value = rawValue.trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + const quote = value[0]; + const unquoted = value.slice(1, -1); + if (quote === '"') { + return unquoted + .replace(/\\n/g, '\n') + .replace(/\\r/g, '\r') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"'); + } + return unquoted; + } + + // Strip trailing comments for unquoted values. + return value.replace(/\s+#.*$/, '').trim(); +} + +function shouldPreferDotenvValue(key, parsedValue, lockedEnvKeys) { + if (!lockedEnvKeys.has(key)) { + return true; + } + + const isSonarVariable = key.startsWith('SONAR_'); + const isVaultVariable = key.startsWith('VAULT_'); + const isDtrackVariable = key.startsWith('DTRACK_') || key === 'DTR_PROJECT_KEY'; + const isEmptyTokenOverride = key === 'SONAR_TOKEN' && parsedValue === ''; + + if (isEmptyTokenOverride) { + return false; + } + + return isSonarVariable || isVaultVariable || isDtrackVariable; +} + +function shouldImportFromShell(key) { + return ( + key.startsWith('SONAR_') || + key.startsWith('VAULT_') || + key.startsWith('DTRACK_') || + key === 'DTR_PROJECT_KEY' + ); +} + +function loadEnvFile(filePath, lockedEnvKeys) { + if (!fs.existsSync(filePath)) { + return []; + } + + const loadedKeys = []; + const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { + continue; + } + + const normalizedLine = trimmed.startsWith('export ') + ? trimmed.slice('export '.length).trim() + : trimmed; + + // Restrict to conventional env variable names. + const match = normalizedLine.match(/^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/); + if (!match) { + continue; + } + + const [, key, rawValue] = match; + const parsedValue = parseEnvValue(rawValue); + if (!shouldPreferDotenvValue(key, parsedValue, lockedEnvKeys)) { + continue; + } + + process.env[key] = parsedValue; + loadedKeys.push(key); + } + + return loadedKeys; +} + +function loadEnvViaBash(projectRoot, lockedEnvKeys) { + if (!fs.existsSync(path.join(projectRoot, '.env'))) { + return []; + } + + const shellScript = ` +if [ -f "./.env.vault" ]; then + . ./.env.vault || true +fi +if [ -f "./.env" ]; then + set -a + . ./.env || true + set +a +fi +env -0 +`; + + const result = spawnSync('bash', ['-lc', shellScript], { + cwd: projectRoot, + encoding: 'utf8', + stdio: ['inherit', 'pipe', 'inherit'], + env: process.env, + }); + + if (result.error && result.error.code === 'ENOENT') { + return []; + } + + if (result.error || result.status !== 0 || !result.stdout) { + return []; + } + + const loadedKeys = []; + const entries = result.stdout.split('\0').filter(Boolean); + for (const entry of entries) { + const delimiterIndex = entry.indexOf('='); + if (delimiterIndex <= 0) { + continue; + } + + const key = entry.slice(0, delimiterIndex); + const value = entry.slice(delimiterIndex + 1); + if (!shouldImportFromShell(key)) { + continue; + } + + if (!shouldPreferDotenvValue(key, value, lockedEnvKeys)) { + continue; + } + + process.env[key] = value; + loadedKeys.push(key); + } + + return loadedKeys; +} + +const projectRoot = path.resolve(__dirname, '..'); +const lockedEnvKeys = new Set(Object.keys(process.env)); +const loadedEnvKeys = []; + +const bashLoadedKeys = loadEnvViaBash(projectRoot, lockedEnvKeys); +loadedEnvKeys.push(...bashLoadedKeys); +if (bashLoadedKeys.length === 0) { + loadedEnvKeys.push(...loadEnvFile(path.join(projectRoot, '.env'), lockedEnvKeys)); +} +loadedEnvKeys.push(...loadEnvFile(path.join(projectRoot, '.env.local'), lockedEnvKeys)); + +if (loadedEnvKeys.length > 0) { + const uniqueLoadedKeys = [...new Set(loadedEnvKeys)]; + console.log(`Loaded environment variables: ${uniqueLoadedKeys.join(', ')}`); } else { - console.log('No .env file found, using existing environment variables'); + console.log('No .env values loaded; using existing environment variables'); } -// Check environment variables -const sonarToken = process.env.SONAR_TOKEN; -const sonarUrl = process.env.SONAR_URL; +function runWithNativeScanner(scannerOptions) { + const scannerBinary = resolveNativeScannerPath(); + if (!scannerBinary) { + return false; + } -if (!sonarToken) { - console.error('Error: SONAR_TOKEN environment variable is required'); - process.exit(1); + console.log(`Using native sonar-scanner: ${scannerBinary}`); + const args = Object.entries(scannerOptions).map(([key, value]) => `-D${key}=${value}`); + const useWindowsShell = process.platform === 'win32' && scannerBinary.toLowerCase().endsWith('.bat'); + const result = spawnSync(scannerBinary, args, { + cwd: projectRoot, + stdio: 'inherit', + shell: useWindowsShell, + }); + + if (result.error) { + throw result.error; + } + + if (typeof result.status === 'number' && result.status !== 0) { + throw new Error(`sonar-scanner exited with status ${result.status}`); + } + + return true; } -if (!sonarUrl) { - console.error('Error: SONAR_URL environment variable is required'); - process.exit(1); +function resolveNativeScannerPath() { + const configuredPath = process.env.SONAR_SCANNER_PATH; + if (configuredPath && fs.existsSync(configuredPath)) { + return configuredPath; + } + + const locator = process.platform === 'win32' ? 'where' : 'which'; + const locatorResult = spawnSync(locator, ['sonar-scanner'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + if (locatorResult.status === 0) { + const scannerPath = (locatorResult.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + if (scannerPath && fs.existsSync(scannerPath) && !isNpmScannerWrapperPath(scannerPath)) { + return scannerPath; + } + } + + const homeDir = process.env.HOME || process.env.USERPROFILE; + if (!homeDir) { + return null; + } + + const sonarDir = path.join(homeDir, '.sonar'); + if (!fs.existsSync(sonarDir)) { + return null; + } + + const scannerDirs = fs + .readdirSync(sonarDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith('sonar-scanner-')) + .map((entry) => entry.name) + .sort() + .reverse(); + + const binaryName = process.platform === 'win32' ? 'sonar-scanner.bat' : 'sonar-scanner'; + for (const dirName of scannerDirs) { + const candidate = path.join(sonarDir, dirName, 'bin', binaryName); + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return null; } +function isNpmScannerWrapperPath(scannerPath) { + const normalizedPath = path.normalize(scannerPath).toLowerCase(); + const wrapperSuffix = path + .join('node_modules', '.bin', process.platform === 'win32' ? 'sonar-scanner.cmd' : 'sonar-scanner') + .toLowerCase(); + return normalizedPath.endsWith(wrapperSuffix); +} + +// Check environment variables +const sonarToken = (process.env.SONAR_TOKEN || '').trim(); +const sonarUrl = process.env.SONAR_URL || process.env.SONAR_HOST_URL || 'http://localhost:9000'; + // Validate URL format try { new URL(sonarUrl); @@ -53,6 +261,9 @@ try { console.log('Starting SonarQube scan...'); console.log(`SonarQube Server: ${sonarUrl}`); +if (!sonarToken) { + console.log('SONAR_TOKEN not set; attempting unauthenticated scan'); +} // Run code coverage if it doesn't exist yet const coveragePath = path.join(__dirname, '..', 'coverage', 'lcov.info'); @@ -74,7 +285,12 @@ const properties = {}; propertiesContent.split('\n').forEach((line) => { line = line.trim(); if (line && !line.startsWith('#')) { - const [key, value] = line.split('=').map((part) => part.trim()); + const delimiterIndex = line.indexOf('='); + if (delimiterIndex === -1) { + return; + } + const key = line.slice(0, delimiterIndex).trim(); + const value = line.slice(delimiterIndex + 1).trim(); if (key && value) { properties[key] = value; } @@ -83,53 +299,72 @@ propertiesContent.split('\n').forEach((line) => { // Check if project key is provided in the environment const projectKey = - process.env.SONAR_PROJECT_KEY || properties['sonar.projectKey'] || 'ai-code-prep'; + process.env.SONAR_PROJECT_KEY || + process.env.SONAR_PROJECT || + properties['sonar.projectKey'] || + 'ai-code-prep'; +const projectName = process.env.SONAR_PROJECT_NAME || properties['sonar.projectName']; +const projectVersion = process.env.SONAR_PROJECT_VERSION || properties['sonar.projectVersion']; // Run SonarQube scan console.log('Running SonarQube scan...'); console.log(`Project Key: ${projectKey}`); try { - sonarqubeScanner( - { - serverUrl: sonarUrl, - token: sonarToken, - options: { - 'sonar.projectKey': projectKey, - 'sonar.projectName': properties['sonar.projectName'] || 'Repository AI Code Fusion', - 'sonar.projectVersion': properties['sonar.projectVersion'] || '0.1.0', - 'sonar.sources': properties['sonar.sources'] || 'src', - 'sonar.exclusions': - properties['sonar.exclusions'] || - 'node_modules/**,dist/**,**/*.test.js,**/*.test.jsx,**/*.spec.js,**/*.spec.jsx,coverage/**', - 'sonar.tests': properties['sonar.tests'] || 'src/__tests__', - 'sonar.test.inclusions': - properties['sonar.test.inclusions'] || - '**/*.test.js,**/*.test.jsx,**/*.spec.js,**/*.spec.jsx', - 'sonar.javascript.lcov.reportPaths': - properties['sonar.javascript.lcov.reportPaths'] || 'coverage/lcov.info', - 'sonar.sourceEncoding': properties['sonar.sourceEncoding'] || 'UTF-8', - }, - }, - (result) => { - if (result) { - console.error('SonarQube scan failed:', result); - console.log('\nPossible authorization issues:'); - console.log( - '1. Make sure your SONAR_TOKEN has correct permissions on the SonarQube server' - ); - console.log( - '2. Check if the project exists on the server or if you have permission to create it' - ); - console.log( - '3. Verify the token has not expired and is valid for the specified project key' - ); - process.exit(1); - } else { - console.log('SonarQube scan completed successfully!'); - } + const scannerOptions = { + 'sonar.projectKey': projectKey, + 'sonar.projectName': projectName || 'Repository AI Code Fusion', + 'sonar.projectVersion': projectVersion || '0.1.0', + 'sonar.sources': properties['sonar.sources'] || 'src', + 'sonar.exclusions': + properties['sonar.exclusions'] || + 'node_modules/**,dist/**,**/*.test.js,**/*.test.jsx,**/*.spec.js,**/*.spec.jsx,coverage/**', + 'sonar.tests': properties['sonar.tests'] || 'src/__tests__', + 'sonar.test.inclusions': + properties['sonar.test.inclusions'] || + '**/*.test.js,**/*.test.jsx,**/*.spec.js,**/*.spec.jsx', + 'sonar.javascript.lcov.reportPaths': + properties['sonar.javascript.lcov.reportPaths'] || 'coverage/lcov.info', + 'sonar.sourceEncoding': properties['sonar.sourceEncoding'] || 'UTF-8', + 'sonar.host.url': sonarUrl, + }; + + if (sonarToken) { + scannerOptions['sonar.token'] = sonarToken; + } + + if (runWithNativeScanner(scannerOptions)) { + console.log('SonarQube scan completed successfully!'); + process.exit(0); + } + + console.log('No native sonar-scanner found in PATH; falling back to npm scanner wrapper'); + + const scannerConfig = { + serverUrl: sonarUrl, + options: scannerOptions, + }; + + if (sonarToken) { + scannerConfig.token = sonarToken; + } + + sonarqubeScanner(scannerConfig, (result) => { + if (result) { + console.error('SonarQube scan failed:', result); + console.log('\nPossible authorization issues:'); + console.log('1. Make sure your SONAR_TOKEN has correct permissions on the SonarQube server'); + console.log( + '2. Check if the project exists on the server or if you have permission to create it' + ); + console.log( + '3. Verify the token has not expired and is valid for the specified project key' + ); + process.exit(1); + } else { + console.log('SonarQube scan completed successfully!'); } - ); + }); } catch (error) { console.error('Error running SonarQube scan:', error.message); process.exit(1); diff --git a/scripts/vault-helper.sh b/scripts/vault-helper.sh new file mode 100755 index 0000000..d908859 --- /dev/null +++ b/scripts/vault-helper.sh @@ -0,0 +1,250 @@ +#!/usr/bin/env bash + +vault_helper::log_info() { + printf '[INFO] %s\n' "$1" +} + +vault_helper::log_warn() { + printf '[WARN] %s\n' "$1" +} + +vault_helper::log_error() { + printf '[ERROR] %s\n' "$1" >&2 +} + +vault_helper::require_cli() { + local bin="$1" + if ! command -v "$bin" >/dev/null 2>&1; then + vault_helper::log_error "Required command '$bin' not found in PATH" + return 1 + fi +} + +vault_helper::trim_token() { + tr -d '\r\n' <<<"$1" +} + +vault_helper::trim_string() { + local str="$1" + str="${str#"${str%%[![:space:]]*}"}" + str="${str%"${str##*[![:space:]]}"}" + printf '%s' "$str" +} + +vault_helper::load_token_from_file() { + local file="$1" + [[ -r "$file" ]] || return 1 + vault_helper::trim_token "$(cat "$file")" +} + +vault_helper::save_token() { + local token="$1" + local file="$2" + mkdir -p "$(dirname "$file")" + umask 077 + printf '%s\n' "$token" >"$file" + chmod 600 "$file" 2>/dev/null || true + vault_helper::log_info "Saved Vault token to $file" +} + +vault_helper::validate_token() { + local token="$1" + [[ -n "$token" ]] || return 1 + if VAULT_TOKEN="$token" vault token lookup >/dev/null 2>&1; then + return 0 + fi + return 1 +} + +vault_helper::authenticate() { + local username password login_json token + read -r -p "Vault username: " username >&2 + read -r -s -p "Vault password: " password + echo >&2 + + if ! login_json=$(vault login -format=json -method=userpass username="$username" password="$password" 2>/dev/null); then + vault_helper::log_error "Vault authentication failed" + return 1 + fi + + token=$(jq -r '.auth.client_token // empty' <<<"$login_json") + unset login_json + + if [[ -z "$token" ]]; then + vault_helper::log_error "Vault login response did not include a token" + return 1 + fi + + VAULT_TOKEN="$token" + export VAULT_TOKEN + vault_helper::save_token "$token" "$VAULT_TOKEN_FILE" + + unset username password token + return 0 +} + +vault_helper::set_secret_if_empty() { + local var="$1" + local value="$2" + local source="$3" + + if [[ -z "${!var:-}" && -n "$value" ]]; then + printf -v "$var" '%s' "$value" + export "$var" + vault_helper::log_info "Mapped ${source} -> ${var}" + fi +} + +vault_helper::apply_mappings() { + local json="$1" + local path="$2" + local mappings="$3" + local normalized entry var key value + + [[ -z "$mappings" ]] && return 0 + + normalized=$(printf '%s' "$mappings" | tr ',;' ' ') + for entry in $normalized; do + [[ "$entry" != *=* ]] && continue + var="$(vault_helper::trim_string "${entry%%=*}")" + key="$(vault_helper::trim_string "${entry#*=}")" + [[ -z "$var" || -z "$key" ]] && continue + value=$(jq -r --arg k "$key" '.[$k] // empty' <<<"$json") + if [[ -n "$value" && "$value" != "null" ]]; then + vault_helper::set_secret_if_empty "$var" "$value" "${key}@${path}" + fi + done +} + +vault_helper::fetch_and_export() { + local path="$1" + local mappings="$2" + local payload exports count data_json + + vault_helper::log_info "Fetching secrets from ${path}..." + if ! payload=$(vault kv get -format=json "$path" 2>&1); then + vault_helper::log_error "Failed to fetch secrets from ${path}" + vault_helper::log_error "$payload" + return 1 + fi + + if ! data_json=$(printf '%s' "$payload" | jq -c '.data.data // .data // {}'); then + vault_helper::log_error "Unable to parse secrets JSON from ${path}" + return 1 + fi + + if [[ "$data_json" == "{}" ]]; then + vault_helper::log_warn "No secrets to export at ${path}" + return 0 + fi + + if ! exports=$(printf '%s' "$data_json" | jq -r ' + to_entries[]? | + "export \(.key)=\(.value | @sh)" + '); then + vault_helper::log_error "Unable to parse secrets from ${path}" + return 1 + fi + + eval "$exports" + vault_helper::apply_mappings "$data_json" "$path" "$mappings" + + count=$(printf '%s' "$data_json" | jq 'length') + vault_helper::log_info "Loaded ${count} secret(s) from ${path}" + return 0 +} + +vault_helper::validate_required_vars() { + local missing=() + local var + for var in "$@"; do + if [[ -z "${!var:-}" ]]; then + missing+=("$var") + fi + done + + if [[ "${#missing[@]}" -gt 0 ]]; then + vault_helper::log_error "Missing required secret(s): ${missing[*]}" + return 1 + fi + + vault_helper::log_info "Validated required secret(s): ${*}" + return 0 +} + +vault_helper::load_from_definitions() { + local secret_defs_raw="$1" + local required_vars_raw="$2" + VAULT_TOKEN_FILE="${3:-$HOME/.vault-token}" + + local -a secret_defs required_vars + local entry path mappings token_from_file + + if [[ -z "$(vault_helper::trim_string "$secret_defs_raw")" ]]; then + vault_helper::log_error "No Vault secret paths configured. Set VAULT_SECRET_PATHS or provide a default." + return 1 + fi + + mapfile -t secret_defs < <(printf '%s\n' "$secret_defs_raw" | awk 'NF') + + if [[ "${#secret_defs[@]}" -eq 0 ]]; then + vault_helper::log_error "No Vault secret paths configured. Set VAULT_SECRET_PATHS or provide a default." + return 1 + fi + + if [[ -n "$(vault_helper::trim_string "$required_vars_raw")" ]]; then + mapfile -t required_vars < <(printf '%s\n' "$required_vars_raw" | tr ', \t' '\n' | awk 'NF') + else + required_vars=() + fi + + if ! vault_helper::require_cli vault || ! vault_helper::require_cli jq; then + return 1 + fi + + if [[ -z "${VAULT_TOKEN:-}" ]]; then + if token_from_file=$(vault_helper::load_token_from_file "$VAULT_TOKEN_FILE" 2>/dev/null); then + VAULT_TOKEN="$token_from_file" + export VAULT_TOKEN + vault_helper::log_info "Loaded Vault token from $VAULT_TOKEN_FILE" + fi + fi + + if vault_helper::validate_token "${VAULT_TOKEN:-}"; then + vault_helper::log_info "Existing Vault token is valid." + else + vault_helper::log_info "Vault token missing or invalid; starting authentication." + if ! vault_helper::authenticate; then + return 1 + fi + fi + + for entry in "${secret_defs[@]}"; do + entry="$(vault_helper::trim_string "$entry")" + path="$entry" + mappings="" + + if [[ "$entry" == *"|"* ]]; then + path="$(vault_helper::trim_string "${entry%%|*}")" + mappings="$(vault_helper::trim_string "${entry#*|}")" + fi + + if [[ -z "$path" ]]; then + vault_helper::log_warn "Skipping empty path definition: $entry" + continue + fi + + if ! vault_helper::fetch_and_export "$path" "$mappings"; then + return 1 + fi + done + + if [[ "${#required_vars[@]}" -gt 0 ]]; then + if ! vault_helper::validate_required_vars "${required_vars[@]}"; then + return 1 + fi + fi + + vault_helper::log_info "Vault secrets loaded successfully." + return 0 +} diff --git a/tailwind.config.js b/tailwind.config.js index 3fdc0c5..87dee32 100755 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,5 +1,5 @@ module.exports = { - content: ['./src/renderer/**/*.{js,jsx}', './src/renderer/index.html'], + content: ['./src/renderer/**/*.{js,jsx,ts,tsx}', './src/renderer/index.html'], darkMode: 'class', theme: { extend: {}, From 18af30caddd9886f4a416fc359f99ff4d08a715c Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sun, 8 Feb 2026 19:47:59 +0000 Subject: [PATCH 2/8] ci: enforce secret scanning in pre-commit and PR gate --- .github/workflows/secrets-gate.yml | 66 ++++++++++++++++++++++++++++++ .husky/pre-commit | 5 +++ 2 files changed, 71 insertions(+) create mode 100644 .github/workflows/secrets-gate.yml diff --git a/.github/workflows/secrets-gate.yml b/.github/workflows/secrets-gate.yml new file mode 100644 index 0000000..d8727be --- /dev/null +++ b/.github/workflows/secrets-gate.yml @@ -0,0 +1,66 @@ +name: Secrets Gate + +on: + pull_request: + branches: ['main'] + push: + branches: ['main'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + gitleaks: + name: Gitleaks Secret Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install gitleaks + env: + GITLEAKS_VERSION: 8.24.3 + run: | + set -euo pipefail + curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" -o gitleaks.tar.gz + tar -xzf gitleaks.tar.gz gitleaks + chmod +x gitleaks + echo "${PWD}" >> "${GITHUB_PATH}" + + - name: Determine git scan range + id: scan-range + shell: bash + run: | + set -euo pipefail + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "log_opts=${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}" >> "${GITHUB_OUTPUT}" + elif [[ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]]; then + echo "log_opts=${{ github.event.before }}..${{ github.sha }}" >> "${GITHUB_OUTPUT}" + else + echo "log_opts=${{ github.sha }}" >> "${GITHUB_OUTPUT}" + fi + + - name: Scan commit range for secrets + run: | + set -euo pipefail + gitleaks git \ + --redact \ + --no-banner \ + --exit-code 1 \ + --log-opts="${{ steps.scan-range.outputs.log_opts }}" \ + --report-format json \ + --report-path gitleaks-report.json + + - name: Upload gitleaks report + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f + with: + name: gitleaks-report + path: gitleaks-report.json + if-no-files-found: ignore + retention-days: 14 diff --git a/.husky/pre-commit b/.husky/pre-commit index 71bee30..8f798c6 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,10 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" +set -e + # Run lint-staged (works on both Windows with Git Bash and Linux) npx lint-staged + +# Block commits that introduce secrets. +npm run gitleaks From f19b820564817a60c4e1ec7ff6121e317022ffb5 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sun, 8 Feb 2026 19:51:45 +0000 Subject: [PATCH 3/8] docs: remove updates and signing planning document --- docs/DEVELOPMENT.md | 2 -- docs/UPDATES_AND_SIGNING.md | 72 ------------------------------------- 2 files changed, 74 deletions(-) delete mode 100644 docs/UPDATES_AND_SIGNING.md diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 4b7868e..a808b2d 100755 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -94,5 +94,3 @@ node scripts/index.js release ``` Where `` is a semantic version (`1.2.3`) or one of `patch`, `minor`, `major`. - -For update metadata and signing rollout planning, see `docs/UPDATES_AND_SIGNING.md`. diff --git a/docs/UPDATES_AND_SIGNING.md b/docs/UPDATES_AND_SIGNING.md deleted file mode 100644 index ab584ef..0000000 --- a/docs/UPDATES_AND_SIGNING.md +++ /dev/null @@ -1,72 +0,0 @@ -# Updates and Signing - -This document defines the lightweight auto-update flow and the signing rollout plan for Windows, macOS, and Linux. - -Current status: releases are intentionally unsigned until certificates and notarization credentials are ready. - -## Current update flow - -The app is planned to use `electron-updater` through IPC-safe handlers in the main process: - -- `updates:getStatus` returns updater state -- `updates:check` triggers a manual check -- `updates:download` downloads an available update -- `updates:quitAndInstall` restarts and installs a downloaded update - -Production behavior target: - -- Updater initializes only for packaged production builds -- Automatic background check runs shortly after startup and then every 6 hours - -## Release artifact requirements - -For `electron-updater` to work, release assets must include metadata files in addition to installers: - -- Windows: installer + `latest*.yml` + `.blockmap` -- macOS: `.zip`/`.dmg` + `latest*.yml` + `.blockmap` -- Linux (AppImage): `.AppImage` + `latest*.yml` (+ `.zsync` when generated) - -The `release.yml` workflow is configured to upload these files. - -## Signing plan - -### 1) Windows signing (Authenticode) - -Use an OV/EV code-signing certificate and configure GitHub secrets: - -- `WINDOWS_CSC_LINK` -- `WINDOWS_CSC_KEY_PASSWORD` - -Electron Builder signs automatically when these are present. - -### 2) macOS signing + notarization - -Use Apple Developer ID Application certificate and notarization credentials: - -- `MACOS_CSC_LINK` -- `MACOS_CSC_KEY_PASSWORD` -- `APPLE_ID` -- `APPLE_ID_PASSWORD` -- `APPLE_TEAM_ID` - -Recommended next step: add explicit notarization validation in CI logs and fail the build if notarization fails. - -### 3) Linux signing (optional) - -Linux app-signing is distribution-specific and less standardized than Windows/macOS: - -- AppImage: optional GPG signing + checksum publication -- Debian/RPM: sign repository metadata and package artifacts - -Pragmatic baseline: - -- Publish SHA256 checksums for Linux artifacts in each release -- Add optional GPG detached signatures for `.AppImage` - -## Rollout checklist - -1. Merge changes and create a release tag (`vX.Y.Z`). -2. Confirm release assets include installers plus update metadata (`latest*.yml`, `.blockmap`, `.zsync`). -3. Configure signing secrets in repository settings. -4. Produce first signed release on Windows and macOS. -5. Validate update path end-to-end from previous version to latest version on each OS. From f5d72aaea4b4a5c9b4941ab439b1a165ab790a84 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sun, 8 Feb 2026 20:10:47 +0000 Subject: [PATCH 4/8] chore: redact sonar server URL in scanner logs --- scripts/sonar-scan.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/sonar-scan.js b/scripts/sonar-scan.js index bf3ff31..6a58fc9 100755 --- a/scripts/sonar-scan.js +++ b/scripts/sonar-scan.js @@ -4,6 +4,16 @@ const fs = require('fs'); const path = require('path'); const sonarqubeScanner = require('sonarqube-scanner'); +function redactUrlForLogs(rawUrl) { + try { + const parsed = new URL(rawUrl); + const portSegment = parsed.port ? `:${parsed.port}` : ''; + return `${parsed.protocol}//${portSegment}`; + } catch (_error) { + return ''; + } +} + function parseEnvValue(rawValue) { const value = rawValue.trim(); if ( @@ -260,7 +270,7 @@ try { } console.log('Starting SonarQube scan...'); -console.log(`SonarQube Server: ${sonarUrl}`); +console.log(`SonarQube Server: ${redactUrlForLogs(sonarUrl)}`); if (!sonarToken) { console.log('SONAR_TOKEN not set; attempting unauthenticated scan'); } From 05ba98e1ae5d1b03bea5c33457edebbae5302a68 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sun, 8 Feb 2026 20:19:01 +0000 Subject: [PATCH 5/8] test: beef up mock repo and resized tree screenshot coverage --- scripts/capture-ui-screenshot.js | 222 ++++++++++++++++++++++++++----- 1 file changed, 192 insertions(+), 30 deletions(-) diff --git a/scripts/capture-ui-screenshot.js b/scripts/capture-ui-screenshot.js index b582ea5..b573866 100644 --- a/scripts/capture-ui-screenshot.js +++ b/scripts/capture-ui-screenshot.js @@ -104,6 +104,7 @@ function createStaticServer() { const MOCK_ROOT_PATH = '/mock-repository'; const MOCK_APP_FILE_PATH = `${MOCK_ROOT_PATH}/src/App.tsx`; +const MOCK_FEATURE_MODULE_COUNT = 24; const MOCK_CONFIG = [ 'include_extensions:', ' - .ts', @@ -114,38 +115,155 @@ const MOCK_CONFIG = [ 'use_gitignore: true', ].join('\n'); -const MOCK_DIRECTORY_TREE = [ - { - type: 'directory', - name: 'src', - path: `${MOCK_ROOT_PATH}/src`, - children: [ - { - type: 'file', - name: 'App.tsx', - path: MOCK_APP_FILE_PATH, - }, - { - type: 'file', - name: 'index.tsx', - path: `${MOCK_ROOT_PATH}/src/index.tsx`, - }, - ], - }, - { +function toMockPath(relativePath) { + const normalized = String(relativePath).replace(/\\/g, '/').replace(/^\/+/, ''); + return `${MOCK_ROOT_PATH}/${normalized}`; +} + +function getMockName(relativePath) { + const normalized = String(relativePath).replace(/\\/g, '/').replace(/^\/+/, ''); + const segments = normalized.split('/').filter(Boolean); + return segments.length > 0 ? segments[segments.length - 1] : normalized; +} + +function createMockFile(relativePath) { + return { + type: 'file', + name: getMockName(relativePath), + path: toMockPath(relativePath), + }; +} + +function createMockDirectory(relativePath, children) { + return { type: 'directory', - name: 'tests', - path: `${MOCK_ROOT_PATH}/tests`, - children: [ - { - type: 'file', - name: 'app.test.tsx', - path: `${MOCK_ROOT_PATH}/tests/app.test.tsx`, - }, - ], - }, + name: getMockName(relativePath), + path: toMockPath(relativePath), + children, + }; +} + +function createFeatureModule(moduleIndex) { + const moduleId = String(moduleIndex).padStart(2, '0'); + const moduleName = `feature-${moduleId}`; + const featureComponent = `Feature${moduleId}`; + const basePath = `src/features/${moduleName}`; + + return createMockDirectory(basePath, [ + createMockFile(`${basePath}/index.ts`), + createMockFile(`${basePath}/model.ts`), + createMockDirectory(`${basePath}/hooks`, [ + createMockFile(`${basePath}/hooks/use${featureComponent}.ts`), + createMockFile(`${basePath}/hooks/use${featureComponent}Filters.ts`), + ]), + createMockDirectory(`${basePath}/ui`, [ + createMockFile(`${basePath}/ui/${featureComponent}Panel.tsx`), + createMockFile(`${basePath}/ui/${featureComponent}Toolbar.tsx`), + createMockFile(`${basePath}/ui/${featureComponent}Table.tsx`), + ]), + ]); +} + +function createPackageModule(moduleIndex) { + const moduleId = String(moduleIndex).padStart(2, '0'); + const moduleName = `shared-${moduleId}`; + const basePath = `packages/${moduleName}`; + + return createMockDirectory(basePath, [ + createMockFile(`${basePath}/package.json`), + createMockDirectory(`${basePath}/src`, [ + createMockFile(`${basePath}/src/index.ts`), + createMockFile(`${basePath}/src/${moduleName}.ts`), + createMockFile(`${basePath}/src/${moduleName}.test.ts`), + ]), + ]); +} + +function createTestSuite(moduleIndex) { + const moduleId = String(moduleIndex).padStart(2, '0'); + return createMockFile(`tests/integration/feature-${moduleId}.spec.ts`); +} + +function countMockFiles(items) { + let count = 0; + for (const item of items) { + if (item.type === 'file') { + count += 1; + continue; + } + if (item.children) { + count += countMockFiles(item.children); + } + } + return count; +} + +const mockFeatures = Array.from({ length: MOCK_FEATURE_MODULE_COUNT }, (_, index) => + createFeatureModule(index + 1) +); + +const mockPackages = Array.from({ length: 10 }, (_, index) => createPackageModule(index + 1)); +const mockIntegrationTests = Array.from({ length: 16 }, (_, index) => createTestSuite(index + 1)); + +const MOCK_DEEP_FEATURE_NAME = `feature-${String(MOCK_FEATURE_MODULE_COUNT).padStart(2, '0')}`; +const MOCK_DEEP_FEATURE_FILE_PATH = toMockPath( + `src/features/${MOCK_DEEP_FEATURE_NAME}/ui/Feature${String(MOCK_FEATURE_MODULE_COUNT).padStart( + 2, + '0' + )}Panel.tsx` +); + +const MOCK_DIRECTORY_TREE = [ + createMockDirectory('src', [ + createMockFile('src/App.tsx'), + createMockFile('src/index.tsx'), + createMockFile('src/bootstrap.ts'), + createMockDirectory('src/components', [ + createMockFile('src/components/NavBar.tsx'), + createMockFile('src/components/Footer.tsx'), + createMockDirectory('src/components/common', [ + createMockFile('src/components/common/Button.tsx'), + createMockFile('src/components/common/Card.tsx'), + createMockFile('src/components/common/Modal.tsx'), + ]), + ]), + createMockDirectory('src/hooks', [ + createMockFile('src/hooks/useDebouncedValue.ts'), + createMockFile('src/hooks/useRepositoryScan.ts'), + createMockFile('src/hooks/useTheme.ts'), + ]), + createMockDirectory('src/utils', [ + createMockFile('src/utils/path-utils.ts'), + createMockFile('src/utils/filter-utils.ts'), + createMockFile('src/utils/token-utils.ts'), + ]), + createMockDirectory('src/features', mockFeatures), + ]), + createMockDirectory('packages', mockPackages), + createMockDirectory('tests', [ + createMockDirectory('tests/unit', [ + createMockFile('tests/unit/app.test.tsx'), + createMockFile('tests/unit/file-tree.test.tsx'), + createMockFile('tests/unit/filtering.test.ts'), + ]), + createMockDirectory('tests/integration', mockIntegrationTests), + createMockDirectory('tests/e2e', [ + createMockFile('tests/e2e/file-selection.spec.ts'), + createMockFile('tests/e2e/resize-regression.spec.ts'), + createMockFile('tests/e2e/filters-regression.spec.ts'), + ]), + ]), + createMockDirectory('docs', [ + createMockFile('docs/README.md'), + createMockFile('docs/CONFIGURATION.md'), + createMockFile('docs/ARCHITECTURE.md'), + createMockFile('docs/SECURITY.md'), + createMockFile('docs/TROUBLESHOOTING.md'), + ]), ]; +const MOCK_TOTAL_FILE_COUNT = countMockFiles(MOCK_DIRECTORY_TREE); + const SCREENSHOT_NAME = sanitizeScreenshotName(process.env.UI_SCREENSHOT_NAME); const SCREENSHOT_BASE_NAME = path.parse(SCREENSHOT_NAME).name; const SCREENSHOT_PATH = resolveOutputPath(SCREENSHOT_NAME); @@ -162,7 +280,12 @@ const UI_SELECTORS = { configTab: '[data-tab="config"]', sourceTab: '[data-tab="source"]', sourceFolderExpandButton: 'button[aria-label="Expand folder src"]', + sourceFeaturesFolderExpandButton: 'button[aria-label="Expand folder features"]', + sourceDeepFeatureFolderExpandButton: `button[aria-label="Expand folder ${MOCK_DEEP_FEATURE_NAME}"]`, + sourceDeepUiFolderExpandButton: 'button[aria-label="Expand folder ui"]', appFileEntry: `[title="${MOCK_APP_FILE_PATH}"]`, + deepFeatureFileEntry: `[title="${MOCK_DEEP_FEATURE_FILE_PATH}"]`, + fileTreeScrollContainer: '.file-tree .overflow-auto', }; async function setupMockElectronApi(page) { @@ -253,6 +376,17 @@ async function captureAppStateScreenshots(page) { await page.locator(UI_SELECTORS.sourceFolderExpandButton).first().waitFor({ timeout: 10000 }); }); + await runStep('Wait for large mock file count to load', async () => { + await page.waitForFunction((totalFiles) => { + const fileTreeRoot = document.querySelector('.file-tree'); + if (!fileTreeRoot) { + return false; + } + const summaryText = fileTreeRoot.textContent || ''; + return summaryText.includes(`of ${totalFiles} files selected`); + }, MOCK_TOTAL_FILE_COUNT); + }); + await runStep('Capture source tab screenshot', async () => { await page.screenshot({ path: SCREENSHOTS.sourceTab, fullPage: true }); }); @@ -283,7 +417,35 @@ async function captureAppStateScreenshots(page) { await page.setViewportSize({ width: 960, height: 700 }); }); - await runStep('Capture resized screenshot', async () => { + await runStep('Verify file tree is scrollable after resize', async () => { + await page.waitForFunction((selector) => { + const container = document.querySelector(selector); + if (!(container instanceof HTMLElement)) { + return false; + } + return container.scrollHeight > container.clientHeight; + }, UI_SELECTORS.fileTreeScrollContainer); + }); + + await runStep('Expand feature folders in resized viewport', async () => { + await page.locator(UI_SELECTORS.sourceFeaturesFolderExpandButton).first().click(); + await page.locator(UI_SELECTORS.sourceDeepFeatureFolderExpandButton).first().click(); + await page.locator(UI_SELECTORS.sourceDeepUiFolderExpandButton).first().click(); + }); + + await runStep('Select deep feature file after resize', async () => { + const deepFile = page.locator(UI_SELECTORS.deepFeatureFileEntry).first(); + await deepFile.scrollIntoViewIfNeeded(); + await deepFile.click(); + }); + + await runStep('Wait for two selected files after resize', async () => { + await page.waitForFunction(() => { + return document.querySelectorAll('.file-tree input[type="checkbox"]:checked').length === 2; + }); + }); + + await runStep('Capture resized screenshot with deep tree expanded', async () => { await page.screenshot({ path: SCREENSHOTS.sourceSelectedResized, fullPage: true }); }); } From cc493cbdf5348f44de7fa60082371790e2146548 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sun, 8 Feb 2026 20:27:01 +0000 Subject: [PATCH 6/8] docs: add lightweight agent rules for quality and local env --- AGENTS.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4a6e8e1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# AGENTS.md + +Lightweight rules for automated agents and contributors in this repository. + +## Scope + +- Keep changes focused and minimal. +- Do not make unrelated refactors. + +## Tests and Quality + +- Before proposing merge-ready changes, run: + - `npm run lint` + - `npm test -- --runInBand` +- If UI behavior/layout is changed, also run: + - `npm run qa:screenshot` +- Do not mark work complete while required CI checks are failing. + +## `.env` Policy (Local-Only) + +- `.env` in this repo is for local development bootstrap only. +- Keep `.env` tracked; do not remove/rename it. +- Do not over-engineer or heavily refactor `.env` for non-local use. +- Never commit secrets/tokens in `.env`. +- Use Vault/environment-provided secrets for real credentials. + +## Review Focus for Agents + +- Treat `.env` changes as sensitive even when local-only. +- Ensure `.env` updates are minimal, intentional, and documented in PR notes. +- Reject any hardcoded secret, token, or credential exposure in code, docs, or PR text. From 216afe676b912e4f5ae9ee64db5b65eb42f8e01d Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sun, 8 Feb 2026 20:29:31 +0000 Subject: [PATCH 7/8] docs: add lightweight test catalog and reference for agents --- AGENTS.md | 1 + tests/catalog.md | 57 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 tests/catalog.md diff --git a/AGENTS.md b/AGENTS.md index 4a6e8e1..11e93e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,6 +15,7 @@ Lightweight rules for automated agents and contributors in this repository. - If UI behavior/layout is changed, also run: - `npm run qa:screenshot` - Do not mark work complete while required CI checks are failing. +- Use `tests/catalog.md` as the source of truth for test targets and use cases. ## `.env` Policy (Local-Only) diff --git a/tests/catalog.md b/tests/catalog.md new file mode 100644 index 0000000..f912889 --- /dev/null +++ b/tests/catalog.md @@ -0,0 +1,57 @@ +# Test Catalog + +Purpose: quick map of what is covered, why it exists, and which command to run. + +## Core Commands + +- Full tests: `npm test -- --runInBand` +- Lint: `npm run lint` +- UI screenshot gate: `npm run qa:screenshot` + +## Unit Tests + +| File | Primary Target | Key Use Cases | +| -------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------- | +| `tests/unit/components/app.test.tsx` | `src/renderer/components/App.tsx` | Tab switching, config load, directory selection, processing flow, error handling | +| `tests/unit/components/config-tab.test.tsx` | `src/renderer/components/ConfigTab.tsx` | Config toggles/inputs, callback wiring, directory picker trigger | +| `tests/unit/components/file-tree.test.tsx` | `src/renderer/components/FileTree.tsx` | Tree render, folder expand/collapse, select all, empty-state behavior | +| `tests/unit/file-analyzer.test.ts` | `src/utils/file-analyzer.ts` | Include/exclude rules, gitignore behavior, binary handling, error cases | +| `tests/unit/gitignore-parser.test.ts` | `src/utils/gitignore-parser.ts` | Pattern parsing, negation behavior, caching, nested path handling | +| `tests/unit/binary-detection.test.ts` | `src/utils/binary-detection.ts` | Binary signature detection, control-char thresholds, fallback-on-error behavior | +| `tests/unit/utils/filter-utils.test.ts` | `src/utils/filter-utils.ts` | Path normalization, extension filtering, custom excludes, gitignore precedence | +| `tests/unit/utils/fnmatch.test.ts` | `src/utils/fnmatch.ts` | Glob semantics: wildcards, classes, double-star, braces, path anchors | +| `tests/unit/utils/content-processor.test.ts` | `src/utils/content-processor.ts` | Content assembly, binary skip logic, malformed input handling | +| `tests/unit/utils/config-manager.test.ts` | `src/utils/config-manager.ts` | Default config load, parse failures, graceful fallback behavior | +| `tests/unit/utils/token-counter.test.ts` | `src/utils/token-counter.ts` | Token counting basics, empty/null input handling | + +## Integration Tests + +| File | Primary Target | Key Use Cases | +| ------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------- | +| `tests/integration/main-process/handlers.test.ts` | Main IPC handlers | `fs:getDirectoryTree`, `repo:analyze`, `repo:process`, `tokens:countFiles` correctness and failures | +| `tests/integration/pattern-merging.test.ts` | Filtering + gitignore merge behavior | Combined behavior of include/exclude patterns with gitignore toggles | + +## Visual Regression Signal + +| Command | Primary Target | Key Use Cases | +| ----------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------ | +| `npm run qa:screenshot` | `scripts/capture-ui-screenshot.js` + renderer UI | Cross-OS UI sanity, resized layout checks, deep file-tree selection visibility | + +## Change-to-Test Mapping + +- Filtering / gitignore logic: + - `tests/unit/utils/filter-utils.test.ts` + - `tests/unit/gitignore-parser.test.ts` + - `tests/integration/pattern-merging.test.ts` +- File tree / selection UX: + - `tests/unit/components/file-tree.test.tsx` + - `npm run qa:screenshot` +- Renderer flow changes: + - `tests/unit/components/app.test.tsx` + - `tests/unit/components/config-tab.test.tsx` +- Main process / IPC changes: + - `tests/integration/main-process/handlers.test.ts` +- Content/token pipeline changes: + - `tests/unit/file-analyzer.test.ts` + - `tests/unit/utils/content-processor.test.ts` + - `tests/unit/utils/token-counter.test.ts` From 3f21fd16d4e7f431bd820ba91f019279c7de3538 Mon Sep 17 00:00:00 2001 From: Mehdi Date: Sun, 8 Feb 2026 20:44:50 +0000 Subject: [PATCH 8/8] Add Dependabot config and finalize TS UI fixes --- .github/dependabot.yml | 19 +++++++ .gitignore | 3 ++ src/renderer/components/App.tsx | 10 ++-- src/renderer/components/DarkModeToggle.tsx | 1 + src/renderer/components/FileTree.tsx | 4 +- src/renderer/components/ProcessedTab.tsx | 58 ++++++++++++++-------- src/renderer/components/SourceTab.tsx | 29 ++++++----- src/renderer/components/TabBar.tsx | 9 ++-- src/types/global.d.ts | 4 +- src/types/ipc.ts | 4 ++ 10 files changed, 93 insertions(+), 48 deletions(-) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7ee7f01 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: npm + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + labels: + - dependencies + - dependabot + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + labels: + - dependencies + - dependabot diff --git a/.gitignore b/.gitignore index c393129..1284783 100755 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,6 @@ start/* src/renderer/bundle.js.LICENSE.txt src/renderer/bundle.js.map src/renderer/bundle.js + +# Test artifacts +/test-results diff --git a/src/renderer/components/App.tsx b/src/renderer/components/App.tsx index 1a0312d..0defa89 100755 --- a/src/renderer/components/App.tsx +++ b/src/renderer/components/App.tsx @@ -12,6 +12,7 @@ import type { DirectoryTreeItem, ProcessRepositoryOptions, ProcessRepositoryResult, + TabId, } from '../../types/ipc'; // Helper function to ensure consistent error handling @@ -20,8 +21,6 @@ const ensureError = (error: unknown): Error => { return new Error(String(error)); }; -type TabId = 'config' | 'source' | 'processed'; - type ProcessingOptions = { showTokenCount: boolean; includeTreeView: boolean; @@ -578,7 +577,12 @@ const App = () => { {/* Tab content */} -
+
{activeTab === 'config' && ( )} diff --git a/src/renderer/components/DarkModeToggle.tsx b/src/renderer/components/DarkModeToggle.tsx index ba989b9..fe91823 100644 --- a/src/renderer/components/DarkModeToggle.tsx +++ b/src/renderer/components/DarkModeToggle.tsx @@ -9,6 +9,7 @@ const DarkModeToggle = () => { onClick={toggleDarkMode} className='p-2 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700' title={darkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'} + aria-label={darkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'} > {darkMode ? ( // Sun icon for light mode diff --git a/src/renderer/components/FileTree.tsx b/src/renderer/components/FileTree.tsx index 0be2116..8e3307c 100755 --- a/src/renderer/components/FileTree.tsx +++ b/src/renderer/components/FileTree.tsx @@ -1,7 +1,5 @@ import React, { useMemo, useState } from 'react'; -import type { DirectoryTreeItem } from '../../types/ipc'; - -type SelectionHandler = (path: string, isSelected: boolean) => void; +import type { DirectoryTreeItem, SelectionHandler } from '../../types/ipc'; type FileTreeItemProps = { item: DirectoryTreeItem; diff --git a/src/renderer/components/ProcessedTab.tsx b/src/renderer/components/ProcessedTab.tsx index 2be68c2..46e113b 100755 --- a/src/renderer/components/ProcessedTab.tsx +++ b/src/renderer/components/ProcessedTab.tsx @@ -14,34 +14,48 @@ const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps) const handleSave = async () => { setIsSaving(true); - await onSave(); - setTimeout(() => { - setIsSaving(false); - }, 1000); + try { + await onSave(); + } catch (error) { + console.error('Failed to save:', error); + } finally { + setTimeout(() => { + setIsSaving(false); + }, 1000); + } }; const handleRefresh = async () => { if (onRefresh) { setIsRefreshing(true); - await onRefresh(); - setTimeout(() => { - setIsRefreshing(false); - }, 1000); + try { + await onRefresh(); + } catch (error) { + console.error('Failed to refresh:', error); + } finally { + setTimeout(() => { + setIsRefreshing(false); + }, 1000); + } } }; - const handleCopy = () => { + const handleCopy = async () => { if (processedResult) { - navigator.clipboard.writeText(processedResult.content); - setIsCopied(true); - setTimeout(() => { - setIsCopied(false); - }, 2000); + try { + await navigator.clipboard.writeText(processedResult.content); + setIsCopied(true); + setTimeout(() => { + setIsCopied(false); + }, 2000); + } catch (error) { + console.error('Failed to copy to clipboard:', error); + } } }; return ( -
+
{processedResult ? ( <> {/* Action buttons with processing stats in the center */} @@ -103,7 +117,7 @@ const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps)
Files - + {processedResult.processedFiles}
@@ -112,7 +126,7 @@ const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps)
Tokens - + {processedResult.totalTokens.toLocaleString()}
@@ -122,7 +136,7 @@ const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps)
|
Skipped - + {processedResult.skippedFiles}
@@ -133,7 +147,7 @@ const ProcessedTab = ({ processedResult, onSave, onRefresh }: ProcessedTabProps)
-
+