diff --git a/.claude/commands/agent-review.md b/.claude/commands/agent-review.md index e6a4ef59cc..ba168cf1e2 100644 --- a/.claude/commands/agent-review.md +++ b/.claude/commands/agent-review.md @@ -128,97 +128,78 @@ done < /tmp/changed_files.txt Read `CLAUDE.md` to understand the project's coding standards and conventions. This context will be shared with all agents. -### Calculate Risk Score +### Build the Review Plan (config engine) -Now calculate the risk score with improved algorithm: +Risk scoring, agent selection, special-pattern detection, and rule resolution are now driven +by the declarative review core (`.claude/review/config.yml`). Run the engine against the diff +manifest gathered above (note **`yarn node`** — plain `node` cannot resolve deps under Yarn PnP): -**Process:** - -1. Read the list of changed files from `/tmp/changed_files.txt` -2. Count lines changed from `/tmp/diff_stat.txt` -3. Apply the risk scoring algorithm: - -**Critical File Patterns (+4 points each):** - -- `pages/api/auth/[...nextauth].page.ts` -- `pages/api/auth/helpers.ts` -- `pages/api/auth/impersonate/` -- `pages/api/graphql-rest.page.ts` -- `pages/api/Schema/index.ts` -- `src/lib/apollo/client.ts` -- `src/lib/apollo/link.ts` -- `src/lib/apollo/cache.ts` -- `next.config.ts` -- `.env` files -- Database migrations -- Payment processing code - -**High-Risk Patterns (+3 points each):** - -- `pages/api/Schema/**/resolvers.ts` -- `**/*.graphql` (excluding tests) -- Financial/donation code (`**/Donation**`, `**/Pledge**`, `**/Gift**`) -- Organization management -- Shared components (`src/components/Shared/**`) -- Authentication flows -- Data synchronization code - -**Medium-Risk (+2 points each):** - -- Main app pages -- Custom hooks -- Utility functions with business logic -- Report generation -- Export/import features - -**Low-Risk (+1 point each):** - -- UI-only components -- Styling changes -- Test files -- Documentation - -**Change Volume Multiplier:** +```bash +REVIEW_DIR=".claude/review" +yarn node "$REVIEW_DIR/engine/plan.cjs" \ + --config "$REVIEW_DIR/config.yml" \ + --schema "$REVIEW_DIR/config.schema.json" \ + --files /tmp/changed_files.txt \ + --stat /tmp/diff_stat.txt \ + --diff /tmp/pr_diff.txt \ + --scope "${REVIEW_SCOPE:-single_feature}" \ + > /tmp/review_plan.json +cat /tmp/review_plan.json +``` -- <50 lines: +0 -- 50-200 lines: +1 -- 200-500 lines: +2 -- 500-1000 lines: +3 -- 1000+ lines: +4 +`REVIEW_SCOPE` is the heuristic scope the model sets based on the change footprint (default +`single_feature`; use `cross_cutting` for changes spanning unrelated feature areas or core +infrastructure). The plan JSON has this shape: -**Scope Multiplier:** +```json +{ + "profile": "standard", + "risk": { + "score": 0, + "level": "LOW", + "reviewer": "...", + "factors": { "patternScore": 0, "volumeScore": 0, "specialScore": 0, "scopeMultiplier": 1.0, "subtotal": 0 }, + "special": ["..."] + }, + "agents": [ + { "id": "standards", "model": "smart", "matchedBy": "always", "rules": ["rules/standards.md"] } + ] +} +``` -- Single file: 1.0x -- Single feature area: 1.0x -- Multiple related features: 1.3x -- Cross-cutting changes: 1.7x -- Core infrastructure: 2.0x +### Risk Assessment -**Final Risk Level Classification:** +Read `risk.score`, `risk.level`, `risk.reviewer`, and `risk.special` from `/tmp/review_plan.json` +(do NOT compute the score inline — the engine is the single source of truth). The classification +the engine applies is: - 0-3 points: **LOW** → Entry-level+ can review - 4-6 points: **MEDIUM** → Entry-level+ can review - 7-9 points: **HIGH** → Experienced dev+ should review - 10+ points: **CRITICAL** → Senior dev (Caleb Cox) must review -Calculate and display the summary: +`risk.special[]` lists any special patterns that fired (e.g. `new_dependency`, +`critical_pkg_update`, `lockfile_only_change`, `graphql_without_codegen_check`, +`next_config_security_change`, `apollo_cache_typepolicy_change`) — surface these as risk factors. + +Calculate the day-of-week warning and display the summary: ``` ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 📊 PR RISK ASSESSMENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Risk Score: [X]/[max] -Risk Level: [LOW | MEDIUM | HIGH | CRITICAL] +Risk Score: [risk.score] ← from /tmp/review_plan.json +Risk Level: [risk.level] ← from /tmp/review_plan.json (LOW | MEDIUM | HIGH | CRITICAL) Day: [DAY_OF_WEEK] Files Changed: [N] Lines Changed: +[X] -[Y] Risk Factors Detected: -• [List specific risk factors found] +• [List risk.special[] entries from /tmp/review_plan.json, plus factor highlights] -Required Reviewer Level: +Required Reviewer: [risk.reviewer] ← from /tmp/review_plan.json [LOW/MEDIUM]: ✅ Entry-level or above can review [HIGH]: ⚠️ Experienced developer or above should review [CRITICAL]: 🚨 Senior developer (Caleb Cox) must review @@ -232,91 +213,41 @@ Required Reviewer Level: --- -## Stage 0B — Smart Agent Selection (Standard Mode Only) - -If `AGENT_MODE="standard"`, analyze which agents are actually needed: - -```bash -if [ "$AGENT_MODE" = "standard" ]; then - echo "🤖 Analyzing PR to select relevant agents..." - echo "" +## Stage 0B — Agent Selection (from the config engine) - # Initialize agent list - SELECTED_AGENTS=() - - # Always include these - SELECTED_AGENTS+=("Architecture" "Testing" "Standards") - echo "✅ Architecture Agent - Always included" - echo "✅ Testing Agent - Always included" - echo "✅ Standards Agent - Always included" - - # Security Agent - if auth/API code changed - if grep -q -E "(pages/api/auth|session|jwt|impersonate|authentication)" /tmp/changed_files.txt 2>/dev/null; then - SELECTED_AGENTS+=("Security") - echo "✅ Security Agent - Auth/API code detected" - SECURITY_NEEDED=true - else - echo "❌ Security Agent - No auth/API changes (saved ~\$1.50)" - SECURITY_NEEDED=false - fi +The set of agents to launch is determined by the engine, not by hardcoded `grep` checks. In +`standard` mode, read the `agents[]` array from `/tmp/review_plan.json` — that list **is** the set +of agents to launch. Each entry has: - # Data Integrity Agent - if GraphQL or Apollo changes - if grep -q -E "(\.graphql|apollo|src/lib/apollo)" /tmp/changed_files.txt 2>/dev/null; then - SELECTED_AGENTS+=("Data") - echo "✅ Data Integrity Agent - GraphQL/Apollo changes detected" - DATA_NEEDED=true - else - echo "❌ Data Integrity Agent - No GraphQL changes (saved ~\$1.00)" - DATA_NEEDED=false - fi +- `id` — the agent identifier (`security`, `architecture`, `data-integrity`, `testing`, `ux`, + `financial`, `standards`) +- `model` — the model to use (`smart` | `opus` | `sonnet` | `haiku`) +- `matchedBy` — why the agent was selected (`always`, `path:`, or `content:`) +- `rules` — the rule docs (relative to `.claude/review/`) to load into that agent's prompt - # UX Agent - if UI components changed - if grep -q -E "(\.tsx|components/.*\.tsx)" /tmp/changed_files.txt 2>/dev/null; then - SELECTED_AGENTS+=("UX") - echo "✅ UX Agent - UI components modified" - UX_NEEDED=true - else - echo "❌ UX Agent - No UI changes (saved ~\$1.00)" - UX_NEEDED=false - fi +`deep` mode launches all 7 agents; `quick` mode launches a fixed subset (Testing, UX, Standards). - # Financial Agent - if financial code changed - if grep -q -iE "(donation|pledge|gift|amount|currency|balance|financial)" /tmp/pr_diff.txt 2>/dev/null; then - SELECTED_AGENTS+=("Financial") - echo "✅ Financial Agent - Financial code detected" - FINANCIAL_NEEDED=true - else - echo "❌ Financial Agent - No financial code (saved ~\$1.50)" - FINANCIAL_NEEDED=false - fi +Announce the selection, including each agent's `matchedBy` reason: +```bash +if [ "$AGENT_MODE" = "standard" ]; then + echo "🤖 Agents selected by the config engine:" echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "Selected: ${#SELECTED_AGENTS[@]} of 7 agents" - SAVED_COST=$(( (7 - ${#SELECTED_AGENTS[@]}) * 1 )) - echo "Estimated savings: ~\$$SAVED_COST" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - - # Save for later stages - echo "${SELECTED_AGENTS[@]}" > /tmp/selected_agents.txt + # Each agent in /tmp/review_plan.json's agents[] is launched in Stage 1. + # Announce id + matchedBy reason for each (e.g. "✅ ux — path:src/components/**/*.tsx"). elif [ "$AGENT_MODE" = "quick" ]; then - # Quick mode: only 3 agents + # Quick mode: fixed subset, ignore engine selection echo "Testing UX Standards" > /tmp/selected_agents.txt - SECURITY_NEEDED=false - DATA_NEEDED=false - UX_NEEDED=true - FINANCIAL_NEEDED=false elif [ "$AGENT_MODE" = "deep" ]; then - # Deep mode: all 7 agents + # Deep mode: all 7 agents regardless of triggers echo "Security Architecture Data Testing UX Financial Standards" > /tmp/selected_agents.txt - SECURITY_NEEDED=true - DATA_NEEDED=true - UX_NEEDED=true - FINANCIAL_NEEDED=true fi ``` +In `standard` mode, do not assemble `SELECTED_AGENTS` or `*_NEEDED` flags by hand — the engine's +`agents[]` is authoritative. Launch exactly the agents it lists (mapping `id` → the matching +Stage 1 agent prompt). + --- ## Stage 1 — Launch Specialized Review Agents (Parallel) @@ -325,17 +256,48 @@ Now launch the selected review agents in parallel using the Task tool. **IMPORTANT:** Use a SINGLE message with multiple Task tool invocations to run them in parallel. -Read `/tmp/selected_agents.txt` to determine which agents to launch. +In `standard` mode, the agents to launch come from `/tmp/review_plan.json`'s `agents[]` array (see +Stage 0B). In `quick`/`deep` mode, use the fixed list written to `/tmp/selected_agents.txt`. Map +each engine `id` to the matching agent prompt below: + +- `security` → Agent 1 (Security) +- `architecture` → Agent 2 (Architecture) +- `data-integrity` → Agent 3 (Data Integrity) +- `testing` → Agent 4 (Testing & Quality) +- `ux` → Agent 5 (UX) +- `financial` → Agent 6 (Financial Accuracy) +- `standards` → Agent 7 (MPDX Standards Compliance) Display: "🚀 Launching [N] specialized review agents in parallel..." -**Note**: Only launch agents that are needed based on the mode and smart selection. Check the variables: +**Wire rules + profile into each agent prompt:** + +1. **Rules** — For each agent, read every rule doc listed in its `rules[]` (paths are relative to + `.claude/review/`, e.g. `.claude/review/rules/security.md`) and inject the full contents into + that agent's prompt under a `PROJECT-SPECIFIC RULES` section. These prose docs hold the MPDX + focus areas migrated from `code-review.md` and are authoritative for what the agent checks. +2. **Profile** — Apply the plan's `profile` (from `/tmp/review_plan.json`) to every agent prompt: + - `chill` → "Report only high-confidence, severity ≥ 7 findings; suppress nits." + - `standard` → current behavior (report all severities per the agent's output format). + - `assertive` → "Report all findings including low-severity suggestions." + +Use the agent's `model` field from the plan when launching (falling back to the mode default). + +**Inject approved learnings (learning layer):** Before launching agents, fetch any approved `rule` +learnings so they can be added to the matching agents' prompts (gated on the learning layer being +enabled in config): + +```bash +REVIEW_DIR=".claude/review" +if [ "$(yarn node "$REVIEW_DIR/cli.cjs" config get learning.enabled 2>/dev/null)" = "true" ]; then + yarn node "$REVIEW_DIR/engine/learningsStore.cjs" --rules > /tmp/review_rules.json 2>/dev/null || echo "[]" > /tmp/review_rules.json +fi +``` -- `$SECURITY_NEEDED` - Launch Security Agent if true -- `$DATA_NEEDED` - Launch Data Integrity Agent if true -- `$UX_NEEDED` - Launch UX Agent if true -- `$FINANCIAL_NEEDED` - Launch Financial Agent if true -- Always launch: Architecture, Testing, Standards (in all modes except quick which uses Testing, UX, Standards) +For each entry in `/tmp/review_rules.json` (`{ paths, ruleText, agent }`), inject `ruleText` into +the prompt of the matching agent (the agent named by `agent`, for files under `paths`) using the +same mechanism as `path_rules` — append it under that agent's `PROJECT-SPECIFIC RULES` section. +These are repository-specific learnings ratified by a human from prior review feedback. ### Agent 1: Security Review 🔒 @@ -499,6 +461,8 @@ INSTRUCTIONS: 2. Read FULL content of changed files for context 3. Read CLAUDE.md for project patterns 4. Search for usage patterns of modified components/functions +5. Read /tmp/review_impact.json (if present) for `directDependents`/`topImpacted`. + This change affects these dependent files — verify the change does not break them. CRITICAL FOCUS: @@ -611,6 +575,8 @@ INSTRUCTIONS: 2. Read FULL files for data flow context 3. Search for related GraphQL operations 4. Check for financial calculation changes +5. Read /tmp/review_impact.json (if present) for `directDependents`/`topImpacted`. + This change affects these dependent files — verify the change does not break them. CRITICAL FOCUS: @@ -1216,50 +1182,47 @@ After launching selected agents, display: ## Stage 1B — Dependency Impact Analysis (Parallel) -While agents are running, analyze dependency impact in parallel: +While agents are running, analyze dependency impact in parallel using the index engine +(the persisted import graph), not grep. This is gated on the index being enabled in +`config.yml`. Use **`yarn node`** (plain `node` cannot resolve under Yarn PnP): ```bash -echo "🔍 Analyzing dependency impact..." +echo "🔍 Analyzing dependency impact (index engine)..." echo "" -# For each changed TypeScript/TSX file, find dependents -while IFS= read -r changed_file; do - # Skip non-code files - [[ ! "$changed_file" =~ \.(ts|tsx|js|jsx)$ ]] && continue - - # Extract filename without extension - filename=$(basename "$changed_file" | sed 's/\.[^.]*$//') - - # Search for imports of this file - grep -r "from.*['\"].*$filename['\"]" src/ \ - --include="*.ts" --include="*.tsx" \ - 2>/dev/null | cut -d: -f1 | sort -u > "/tmp/dependents_${filename}.txt" - - dependent_count=$(wc -l < "/tmp/dependents_${filename}.txt" 2>/dev/null || echo 0) +REVIEW_DIR=".claude/review" +if [ "$(yarn node "$REVIEW_DIR/cli.cjs" config get index.enabled 2>/dev/null)" = "true" ]; then + yarn node "$REVIEW_DIR/engine/impact.cjs" \ + --root "$(pwd)" \ + --index "$REVIEW_DIR/index" \ + --changed /tmp/changed_files.txt \ + > /tmp/review_impact.json + cat /tmp/review_impact.json +else + echo "ℹ️ Index disabled in config.yml — skipping impact analysis." +fi - if [ "$dependent_count" -gt 15 ]; then - echo "🚨 CRITICAL IMPACT: $changed_file has $dependent_count dependents" | tee -a /tmp/dependency_impact.txt - elif [ "$dependent_count" -gt 10 ]; then - echo "⚠️ HIGH IMPACT: $changed_file has $dependent_count dependents" | tee -a /tmp/dependency_impact.txt - elif [ "$dependent_count" -gt 5 ]; then - echo "📊 MEDIUM IMPACT: $changed_file has $dependent_count dependents" | tee -a /tmp/dependency_impact.txt - fi -done < /tmp/changed_files.txt +echo "" +echo "✅ Dependency analysis complete" +echo "" +``` -echo "" | tee -a /tmp/dependency_impact.txt +`impact.cjs` builds (or reuses the HEAD-keyed cache of) the import graph and emits a JSON +report on stdout with these fields: -# Check for breaking changes (removed exports) -echo "Checking for breaking changes..." | tee -a /tmp/dependency_impact.txt -git diff $BASE_REF..$HEAD_REF 2>/dev/null | grep "^-export" | grep -v "^---" > /tmp/breaking_changes.txt 2>/dev/null || true +- `directDependents` — `{ [changedFile]: string[] }`, the immediate importers of each changed file +- `transitiveDependents` — flat list of all files transitively reachable as dependents (blast radius set) +- `blastRadius` — count of `transitiveDependents` +- `topImpacted` — `[{ file, dependentCount }]` sorted by direct-dependent count (highest impact first) +- `truncated` — `true` if the `maxNodes` traversal cap was hit -if [ -s /tmp/breaking_changes.txt ]; then - echo "⚠️ BREAKING CHANGES DETECTED:" | tee -a /tmp/dependency_impact.txt - cat /tmp/breaking_changes.txt | tee -a /tmp/dependency_impact.txt -fi +**Display** the `blastRadius` and `topImpacted` files to the user as the dependency-impact +summary (highest-impact changed files first; flag `truncated` if set). -echo "✅ Dependency analysis complete" -echo "" -```` +**Feed dependents into the Architecture and Data Integrity agents** (Stage 1): when launching +those two agents, append the affected `directDependents` / `topImpacted` files from +`/tmp/review_impact.json` to their prompts with the instruction: "This change affects these +dependent files — verify the change does not break them." --- @@ -1317,13 +1280,27 @@ if [ "$FIX_COUNT" -gt 0 ]; then echo "" | tee -a /tmp/fix_summary.txt cat /tmp/fix_summary.txt - # Create master apply script + # Create master apply script. + # SECURITY: these fix_*.sh scripts are MODEL-GENERATED from (attacker-influenceable) PR content + # and are UNTRUSTED. apply_all.sh therefore DRY-RUNS by default — it prints each fix for human + # review and applies nothing unless explicitly re-run with `--yes`. cat > /tmp/automated_fixes/apply_all.sh << 'EOF' #!/bin/bash +set -euo pipefail +# fix_*.sh are model-generated from PR content and UNTRUSTED — review each before applying. +if [ "${1:-}" != "--yes" ]; then + echo "DRY RUN — review each fix, then re-run with --yes to apply. Nothing applied yet." + for fix in /tmp/automated_fixes/fix_*.sh; do + [ -f "$fix" ] || continue + echo ""; echo "===== $(basename "$fix") ====="; cat "$fix" + done + echo ""; echo "To apply after review: bash /tmp/automated_fixes/apply_all.sh --yes" + exit 0 +fi echo "Applying all automated fixes..." for fix in /tmp/automated_fixes/fix_*.sh; do if [ -f "$fix" ]; then - echo "Applying: $(basename $fix)" + echo "Applying: $(basename "$fix")" bash "$fix" fi done @@ -1519,6 +1496,13 @@ Now analyze all findings, debates, and final severity scores to build consensus. - **Average 3-5, 1-2 agents**: SUGGESTION - **Unresolved Debate** (agents couldn't agree, severity differs by 4+): NEEDS HUMAN REVIEW +**Profile-scaled reporting cutoff** (from `/tmp/review_plan.json`'s `profile`): apply the same +floor the agents used so consensus output stays consistent with what was collected: + +- `chill` → only surface consensus findings with average severity ≥ 7; drop MEDIUM/SUGGESTION tiers. +- `standard` → report all tiers above (default). +- `assertive` → report all tiers, including low-severity suggestions, and do not collapse them. + For each grouped finding, determine: - Final severity: Average of all agent severity scores @@ -1686,6 +1670,22 @@ echo "" ## Stage 6 — Generate Review Report +**Capture consensus for the learning layer (gated on learning being enabled):** Write the consensus +findings as a JSON array to `/tmp/consensus_findings.json` — each entry shaped +`{ agent, category, severity, file, line, message }` — then emit them and apply approved learnings: + +```bash +REVIEW_DIR=".claude/review" +yarn node "$REVIEW_DIR/engine/learningsStore.cjs" --emit --in /tmp/consensus_findings.json --review "${REVIEW_ID:-local}" +yarn node "$REVIEW_DIR/engine/learningsStore.cjs" --filter --in .claude/review/learnings/findings.json > /tmp/review_filtered.json +``` + +Report the `kept` findings from `/tmp/review_filtered.json` in the report below, and note the count +of `suppressed` findings (suppressed by approved learnings). Tell the user they can mark outcomes in +the emitted `pending/.yml` (set each finding's `outcome` to `accepted` or `dismissed`), +then run `yarn review:feedback ` and `yarn review:learn` to mine new proposed learnings. +Leave `plan.cjs`, the index, agent selection, and the debate/consensus logic unchanged. + Create the comprehensive review report in markdown format: ```markdown diff --git a/.claude/docs/competitive-research-greptile-coderabbit.md b/.claude/docs/competitive-research-greptile-coderabbit.md new file mode 100644 index 0000000000..2c1ad22c0c --- /dev/null +++ b/.claude/docs/competitive-research-greptile-coderabbit.md @@ -0,0 +1,327 @@ +# Greptile & CodeRabbit — Competitive Research + +> Purpose: document what makes Greptile and CodeRabbit best-in-class AI code reviewers, and +> extract concrete features to fold into our `agent-review` command — with a long-term path +> toward a CLI/UI for configuring and interacting with the reviewer. +> +> Research method: fan-out web search across 6 angles → 25 sources fetched → 122 candidate +> claims → 25 adversarially verified (3-vote, need 2/3 to refute). Only confirmed claims appear +> below. Two claims were explicitly **refuted** and are quarantined at the bottom so they don't +> leak into a spec. Verified ~June 2026; vendor config field names should be re-checked against +> live docs before implementation. + +--- + +## TL;DR — the one-paragraph thesis + +Both products beat diff-only linters by combining **deep codebase context** with a +**configurable, learning-based review pipeline**. Greptile's moat is a **graph of the whole +codebase** (parse → connect → store) that it queries during review for cross-file impact +analysis. CodeRabbit's moat is a **richly configurable review surface**: a global strictness +dial, per-path natural-language rules, 50+ bundled linters/SAST tools, NL-defined pre-merge +checks with warn-vs-block gating, and approval-gated feedback learning. Both **learn from team +behavior** over time and both expose **multiple config channels** (dashboard + repo config files ++ — for Greptile — a CLI with a machine-applicable `--agent` output mode). Our reviewer already +has something neither fully advertises (a multi-agent debate/consensus loop), but we lack their +three pillars: a persistent index, a feedback-learning loop, and a declarative config surface. + +--- + +## Part 1 — Greptile (greptile.com) + +### 1.1 Architecture: a semantic graph of the codebase *(confidence: high, 3-0)* + +Greptile's core is not diff-in-context — it's a **complete graph of the codebase**, built via a +three-step pipeline: + +1. **Parse** every file to extract directories, files, functions, classes, variables. +2. **Connect** all elements: function calls, imports, dependencies, variable usage. +3. **Store** the complete graph for instant querying during reviews. + +At review time it does **cross-file impact analysis** — e.g. "finds everywhere `foo()` is +called," "this change will affect 3 files," and identifies callers of changed symbols. The graph +spans **the target repo and adjacent repos**, so impact analysis crosses repo boundaries. + +> Sources: greptile.com/docs/how-greptile-works/graph-based-codebase-context; greptile.com/learning; +> dev.to/pullflow (independent corroboration). + +### 1.2 Retrieval: NL-translation + per-function chunking *(confidence: high, 3-0 / 2-1)* + +Greptile improves semantic retrieval two ways: + +- **Translate code → natural language before embedding.** Raw code and NL queries aren't + semantically similar; embedding an NL *description* of the code scores measurably higher + against NL queries (illustrative example: query-to-description cosine 0.815 vs query-to-code + 0.728, ~12% relative gap). +- **Chunk per-function, not per-file.** Embedding a whole file dilutes similarity with irrelevant + code (0.739 file vs 0.768 the relevant function alone). + +> Caveat: the 12% figure is from a single illustrative example in a Greptile blog, and +> "production uses per-function chunking" is inferred from a research-framed post, not stated as +> shipped behavior. Treat as directionally true, not a benchmark. +> Source: greptile.com/blog/semantic-codebase-search. + +### 1.3 Layered custom-standards configuration *(confidence: high, 3-0)* + +Three **separate** configuration channels, with a clear precedence model: + +- **Dashboard** — org-wide defaults (lower priority). +- **`greptile.json`** — single repo-wide config file (highest priority). +- **`.greptile/` folders** — can live in *any* directory, not just root (`config.json` / + `rules.md` / `files.json`). Greptile walks from repo root down to the changed file's directory, + collecting and **merging every `.greptile/` folder** along the path. This lets each team in a + monorepo own its own config. + +Dashboard and repo-file configs are explicitly described as **separate systems** (rules in config +files do not appear in the dashboard). + +> Sources: greptile.com/docs/code-review/custom-standards, .../greptile-json, .../greptile-config. +> **Refuted (do not adopt as fact):** that Greptile custom rules require glob-scope + high/medium/low +> severity levels (vote 0-3). + +### 1.4 Learning from team behavior *(confidence: high, 3-0)* + +- **Rule auto-suggestion:** after ~10 PRs, Greptile detects consistent patterns and proposes + rules you can **approve, modify, or ignore**. +- **Continuous feedback:** it learns from **reactions (👍/👎), tags, and what gets merged** to + make reviews more relevant over time. Third parties describe thumbs-up/down calibrating + sensitivity. + +> Sources: greptile.com/docs/code-review/custom-standards, /learning, .../first-pr-review. + +### 1.5 CLI with agent-applyable output *(confidence: high, 3-0)* + +- `npm i -g greptile` → `greptile review` runs a full review on the **local feature-branch diff** + from the terminal (~60s vendor estimate), producing **severity-rated findings, suggested fixes, + and an auto-generated PR summary** — described as identical to its GitHub/GitLab bot output. +- **`greptile review --agent`** emits every finding as **raw text + a suggested fix** for an + autonomous coding agent to apply automatically. Listed agents: Claude Code, OpenAI Codex, + Cursor, Devin, Conductor. + +> This is the single most relevant feature for our CLI/UI vision — a terminal front-end whose +> output a coding agent can consume and auto-apply. +> Sources: greptile.com/cli, greptile.com/docs/code-review/greptile-cli. + +--- + +## Part 2 — CodeRabbit (coderabbit.ai) + +### 2.1 Global severity/verbosity dial *(confidence: high, 3-0)* + +A single `profile` config field scales how much and how pedantic the feedback is: + +- `chill` — lighter feedback (default). +- `assertive` — more feedback, may feel nitpicky. +- `followup` — assertive **plus** tracks whether prior comments were addressed. + +> One knob to scale strictness across the whole review. +> Source: docs.coderabbit.ai/reference/configuration. + +### 2.2 Per-path natural-language rules *(confidence: high, 3-0)* + +`path_instructions`: an array mapping **file glob/minimatch patterns** → **free-text review +guidance** (max 20,000 chars per entry). E.g. `**/*.tsx` → "every user-visible string must use +`t()`." + +> Sources: docs.coderabbit.ai/reference/configuration, .../configuration/path-instructions. + +### 2.3 50+ bundled linters / SAST tools *(confidence: high, 3-0)* + +A `tools` config object wraps **50+ third-party analyzers** — Ruff, ESLint, Biome, Semgrep, +OpenGrep, TruffleHog, Checkov, Trivy, Gitleaks, Brakeman, OSV-Scanner, etc. — **almost all +enabled by default**, many accepting a `config_file` path so existing `.eslintrc`/`pyproject.toml` +configs are reused. The deterministic tool output **feeds the LLM reviewer** rather than replacing +it. + +> Lesson: don't reinvent static analysis — wrap existing OSS tools as pluggable inputs to the LLM. +> Sources: docs.coderabbit.ai/reference/configuration, docs.coderabbit.ai/tools/. + +### 2.4 Feedback learning with scope + approval gate *(confidence: high, 3-0)* + +A `learnings` config (under `knowledge_base`): + +- **scope:** `local` (repo) / `global` (org) / `auto` (local for public repos, global for private). +- **`approval_delay`:** 0–30 days for an admin to approve/reject a learning before it auto-applies. + `0` = apply immediately, no approval. + +> The approval gate is the key idea — learnings don't silently reshape reviews; a human ratifies them. +> Source: docs.coderabbit.ai/reference/configuration. + +### 2.5 Agentic pre-merge checks (warn vs block) *(confidence: high, 3-0)* + +Teams define **org-specific policies in natural language** and CodeRabbit enforces them. Example +checks: no sensitive data in logs, no hardcoded `*_SECRET`/`_KEY`/`_PASSWORD`, DB migrations must +have `up()`/`down()`, breaking API/CLI/env/schema changes must be documented. + +- Configurable via **web UI or committed `.coderabbit.yaml`**. +- Run in **warning mode or error mode** — introduce a guardrail as a warning, then promote it to a + **merge blocker** (error-blocking requires the Request Changes workflow). Enables *gradual* + enforcement. + +> Sources: coderabbit.ai/blog/pre-merge-checks..., docs.coderabbit.ai/pr-reviews/pre-merge-checks, +> .../custom-checks. +> **Refuted (do not adopt as fact):** the specific "five built-in checks" list (80% docstring +> threshold, PR title/description validation, linked-issue verification, issue alignment) — vote 0-3. + +### 2.6 Auto-detect rules from existing agent config files *(confidence: high, 3-0)* + +CodeRabbit ingests existing AI-agent instruction files as review guidelines, so teams don't +re-author rules: + +- Cursor `**/.cursorrules`, `**/.cursor/rules/*` +- Copilot `.github/copilot-instructions.md` +- Cline `**/.clinerules/*` +- Windsurf `**/.windsurfrules` +- **Claude `**/CLAUDE.md`** (plus `AGENTS.md` / `GEMINI.md`) + +> Directly relevant: we already have a rich `CLAUDE.md` + `.claude/rules/code-review.md`. This +> pattern says: treat those as the canonical rule source the reviewer auto-loads. +> Sources: coderabbit.ai/blog/code-guidelines..., docs.coderabbit.ai/knowledge-base/code-guidelines. + +--- + +## Part 3 — Side-by-side + +| Dimension | Greptile | CodeRabbit | Our `agent-review` today | +|---|---|---|---| +| **Codebase context** | Persistent semantic **graph** (parse/connect/store), cross-repo impact analysis | Agentic retrieval + multi-repo analysis | Per-run `grep` + dependency `grep` in Stage 1B — no persistent index | +| **Retrieval** | NL-translation + per-function embeddings | Linter/SAST output + LLM | Diff + full-file reads + ad-hoc grep | +| **Review engine** | LLM over graph queries | LLM + 50+ tools + checks | **7 specialist agents + debate/rebuttal/consensus** (we're ahead here) | +| **Severity control** | (rule suggestions) | Global `profile` dial (chill/assertive/followup) | Per-finding 1–10 severity; consensus thresholds | +| **Custom rules** | Dashboard + `greptile.json` + per-dir `.greptile/` | `path_instructions` (glob→NL) | `.claude/rules/code-review.md` (one static file) | +| **Learning** | Auto-suggest rules after ~10 PRs; learn from reactions/merges | `learnings` scope + approval gate | **None** — cold start every run | +| **Enforcement gating** | — | warn → error (merge blocker) | Report only; no gating | +| **Linters/SAST** | — | 50+ wrapped tools | None bundled (relies on repo's `yarn lint`) | +| **Config channels** | Dashboard + repo files | Web UI + `.coderabbit.yaml` | bash args + markdown rules | +| **CLI/agent output** | `greptile review` + `--agent` machine-applyable | PR bot + chat (`@coderabbitai`) | Slash command only; produces `/tmp` fix scripts | +| **Surface** | PR bot + CLI + dashboard | PR inline comments + chat + dashboard | Local report + metrics dashboard files | + +--- + +## Part 4 — What to adopt, prioritized + +Ordered by leverage-to-effort for our existing command. + +### Tier 1 — high leverage, fits what we already have + +1. **Auto-load `CLAUDE.md` + `.claude/rules/` as canonical rules (CodeRabbit 2.6).** + We already do this informally. Make it explicit and the *single source of truth* the agents + load — the substrate a future UI/CLI edits. Cheapest win. + +2. **A global strictness profile (CodeRabbit 2.1).** + Add a `chill | standard | assertive` dial that scales finding volume and the consensus + severity threshold. Maps cleanly onto our existing `quick/standard/deep` modes — extend rather + than replace. + +3. **Per-path NL rules (CodeRabbit 2.2).** + Generalize `code-review.md`'s pattern lists into glob→instruction entries + (`**/*.tsx` → UX rules, `pages/api/**` → security rules). This *already exists implicitly* in + our Agent Triggers section; formalize it into a parseable structure. + +4. **Wrap existing OSS linters/SAST as agent inputs (CodeRabbit 2.3).** + Run `yarn lint`, `yarn lint:ts`, and a SAST pass (e.g. Semgrep) *first*, then feed results to + the agents so they reason about real findings instead of re-deriving them. High signal, low cost. + +### Tier 2 — the persistent-index and learning gaps (bigger build) + +5. **Cross-file impact analysis on changed symbols (Greptile 1.1).** + Even without a full graph DB: for each changed exported symbol, find callers across the repo + (we already do a crude version in Stage 1B) and feed the **affected call sites** to the + Architecture/Data agents as required context. A `ts-morph`/LSP-backed call graph would make + this real. + +6. **A persistent codebase index (Greptile 1.1–1.2).** + The biggest differentiator. Build/maintain an index (per-function NL summaries + embeddings) + so agents retrieve relevant prior art instead of grepping cold each run. Incremental updates on + push. This is the foundation for the "chat with the reviewer" UX later. + +7. **Feedback learning with approval gating (Greptile 1.4 + CodeRabbit 2.4).** + Persist accept/dismiss outcomes per finding; after N PRs, mine recurring dismissals/accepts into + *proposed* rules that a human approves before they affect future reviews. Scope them repo-vs-org. + The **approval gate is non-negotiable** — never let learnings silently reshape reviews. + +### Tier 3 — enforcement & distribution + +8. **Warn-vs-block enforcement gating (CodeRabbit 2.5).** + Let a rule be a soft warning first, then promote to a merge blocker once the team trusts it. + Pairs naturally with our consensus severity scores. + +9. **A `--agent` machine-applyable output mode (Greptile 1.5).** + We already emit `/tmp/automated_fixes/*.sh`. Add a structured JSON/text mode (finding + file + + suggested patch) that a coding agent (Claude Code) can consume and apply directly — the bridge + to our CLI vision. + +--- + +## Part 5 — Long-term CLI / UI vision (phased) + +The goal: configure and interact with the reviewer the way Greptile (CLI) and CodeRabbit +(dashboard + chat) do. A pragmatic path that reuses our multi-agent engine: + +**Phase A — declarative config + CLI front-end.** +Replace bash-arg config with a committed config file (org defaults + repo file + per-path rules, +à la Greptile's layered model). Wrap the slash command in a thin CLI: `review` (local branch +diff), `review --agent` (machine-applyable output), `config` (edit rules). This phase is mostly +restructuring what we already have. + +**Phase B — persistent index + impact analysis.** +Stand up the codebase index (Tier-2 #5/#6) so the CLI answers "what does this change affect?" and +agents retrieve real prior art. Add incremental re-index on commit. + +**Phase C — learning loop + enforcement.** +Persist feedback, propose approval-gated rules, add warn→block gating. Now the reviewer improves +over time and can guard merges. + +**Phase D — interactive UI.** +A dashboard (and/or chat) over the same engine: view findings, react (👍/👎 → feeds learning), +approve suggested rules, toggle the strictness profile and per-path rules, watch the agent +debate. This is where the index + learning + config from earlier phases pay off — the UI is just +a surface over them. + +> Key architectural principle from both products: **config, index, and learning are separate, +> persistent layers; the review run and the UI/CLI are thin surfaces over them.** Our current +> design bakes everything into one bash-orchestrated run. Splitting those layers is the real work +> behind the vision. + +--- + +## Part 6 — Caveats & open questions + +**Caveats (don't overclaim from this research):** +- **Pricing is unanswered.** No pricing claim survived adversarial voting — zero confirmed + findings on Greptile/CodeRabbit tiers or metering. Needs a fresh, direct check of pricing pages. +- Most Greptile architecture detail comes from its **own docs/blog** — authoritative for "what it + does," not independently benchmarked. The "~60s CLI" and "identical-to-PR-bot quality" are + vendor estimates; the "~12% similarity gap" / per-function benefit come from one illustrative + example. +- **CodeRabbit's internal review orchestration** (whether it's explicitly multi-agent/multi-pass, + and how it sequences linters → LLM → checks) was *not* captured in surviving claims. Notably, + our debate/consensus loop may already be more sophisticated than its public architecture. +- Config field names (`profile`, `path_instructions`, `learnings`, `tools`) evolve — re-verify + against live docs before building. CodeRabbit's pre-merge checks shipped ~Sept/Oct 2025. + +**Refuted claims (quarantined — do NOT carry into a spec):** +- ❌ Greptile custom rules require glob-scope + high/medium/low severity levels (vote 0-3). +- ❌ CodeRabbit has exactly five built-in pre-merge checks with an 80% docstring threshold, + PR title/description validation, linked-issue verification, issue alignment (vote 0-3). + +**Open questions worth a follow-up pass:** +1. Actual pricing tiers and metering (per repo / PR / seat) for both. +2. CodeRabbit's internal pipeline — multi-agent or multi-pass? How are tool outputs sequenced? +3. How Greptile keeps the graph fresh (full re-index vs incremental) and indexing cost on large monorepos. +4. Measured false-positive rates and how reactions/learnings quantitatively change finding volume over time. + +--- + +## Sources (primary, by claim) + +- **Greptile graph/architecture:** greptile.com/docs/how-greptile-works/graph-based-codebase-context +- **Greptile retrieval:** greptile.com/blog/semantic-codebase-search +- **Greptile config:** greptile.com/docs/code-review/custom-standards, .../greptile-json, .../greptile-config +- **Greptile learning:** greptile.com/learning, .../first-pr-review +- **Greptile CLI:** greptile.com/cli, greptile.com/docs/code-review/greptile-cli +- **CodeRabbit config (profile/path_instructions/tools/learnings):** docs.coderabbit.ai/reference/configuration, .../configuration/path-instructions, docs.coderabbit.ai/tools/ +- **CodeRabbit pre-merge checks:** coderabbit.ai/blog/pre-merge-checks-built-in-and-custom-pr-enforced, docs.coderabbit.ai/pr-reviews/pre-merge-checks, .../custom-checks +- **CodeRabbit code-guideline auto-detect:** coderabbit.ai/blog/code-guidelines-bring-your-coding-rules-to-coderabbit, docs.coderabbit.ai/knowledge-base/code-guidelines + +_Generated from a verified deep-research pass (~June 2026). 23 confirmed claims, 2 refuted, pricing unverified._ diff --git a/.claude/review/cli.cjs b/.claude/review/cli.cjs new file mode 100644 index 0000000000..ce6184e9de --- /dev/null +++ b/.claude/review/cli.cjs @@ -0,0 +1,176 @@ +'use strict'; +const { join } = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { readFileSync, writeFileSync, existsSync, rmSync } = require('node:fs'); +const os = require('node:os'); +const { loadConfig } = require('./engine/loadConfig.cjs'); +const { buildPlan, linesChangedFromStat } = require('./engine/plan.cjs'); +const { loadOrBuildIndex, gitHead, listRepoFiles } = require('./engine/indexStore.cjs'); +const { queryImpact } = require('./engine/queryImpact.cjs'); +const { mineLearnings } = require('./engine/mineLearnings.cjs'); +const { parsePending, appendFeedback, loadFeedback, loadLearnings, saveLearnings, mergeProposals } = require('./engine/learningsStore.cjs'); +const { setLearningStatus, listLearnings, preflightSummary } = require('./engine/cliCommands.cjs'); + +const ROOT = process.cwd(); +const RD = join(ROOT, '.claude/review'); +const CONFIG = join(RD, 'config.yml'); +const SCHEMA = join(RD, 'config.schema.json'); +const INDEX = join(RD, 'index'); +const FEEDBACK = join(RD, 'learnings/feedback.jsonl'); +const LEARNINGS = join(RD, 'learnings/learnings.yml'); +const MODES = ['quick', 'standard', 'deep']; + +function out(s) { process.stdout.write(s + '\n'); } + +// Returns the value after `name`, or undefined if absent or the next token is itself a flag. +function flag(argv, name) { + const i = argv.indexOf(name); + if (i < 0) return undefined; + const v = argv[i + 1]; + return v === undefined || v.startsWith('--') ? undefined : v; +} + +function validRef(ref) { + return /^[A-Za-z0-9._/~^-]+$/.test(ref) && !ref.startsWith('-'); +} + +function changedFiles(base) { + let b = base; + if (b && !validRef(b)) throw new Error(`invalid --base ref: "${b}"`); + if (!b) { + try { b = execFileSync('git', ['-C', ROOT, 'merge-base', 'main', 'HEAD'], { encoding: 'utf8' }).trim(); } + catch { b = 'HEAD~1'; } + } + let raw; + try { + raw = execFileSync('git', ['-C', ROOT, 'diff', '--name-only', `${b}...HEAD`], { encoding: 'utf8' }); + } catch (e) { + throw new Error(`could not determine a diff base (tried "${b}"). Pass --base . [${e.message.split('\n')[0]}]`); + } + return { base: b, files: raw.split('\n').map((s) => s.trim()).filter(Boolean) }; +} + +function indexOpts(cfg) { + const ix = (cfg && cfg.index) || {}; + return { aliases: ix.aliases, exts: ix.extensions, roots: ix.roots }; +} + +function loadIndex(cfg, { force } = {}) { + const c = cfg || loadConfig({ configPath: CONFIG, schemaPath: SCHEMA }); + const indexPath = c.index && c.index.path ? join(ROOT, c.index.path) : INDEX; + if (force) { + const gf = join(indexPath, 'graph.json'); + if (existsSync(gf)) rmSync(gf); + } + const opts = indexOpts(c); + return loadOrBuildIndex({ repoRoot: ROOT, indexPath, head: gitHead(ROOT), files: listRepoFiles(ROOT, opts), opts }); +} + +const USAGE = `usage: yarn review + config show|validate|get show / validate / read a config value + index rebuild the import-graph cache + impact [--base ] cross-file blast radius for the current diff + feedback ingest marked outcomes + learn [--min-support N] mine feedback into proposed learnings + learnings [--status S] list learnings + approve | reject set a learning's status + run [--base ] [--scope ] [mode] pre-flight + launch the Claude Code review + help`; + +function main(argv) { + const cmd = argv[0]; + const rest = argv.slice(1); + switch (cmd) { + case 'config': { + const cfg = loadConfig({ configPath: CONFIG, schemaPath: SCHEMA }); + if (rest[0] === 'validate') { out('config OK'); return 0; } + if (rest[0] === 'get') { + if (!rest[1]) { out('usage: yarn review config get '); return 1; } + const val = rest[1].split('.').reduce((o, k) => (o == null ? undefined : o[k]), cfg); + out(val !== null && typeof val === 'object' ? JSON.stringify(val) : String(val)); + return 0; + } + out(JSON.stringify(cfg, null, 2)); + return 0; + } + case 'index': { + const g = loadIndex(undefined, { force: rest.includes('--force') }); + out(`Indexed ${g.fileCount} files; ${Object.keys(g.importedBy).length} have dependents.`); + return 0; + } + case 'impact': { + const { files } = changedFiles(flag(rest, '--base')); + out(JSON.stringify(queryImpact(files, loadIndex(), {}), null, 2)); + return 0; + } + case 'feedback': { + if (!rest[0]) { out('usage: yarn review feedback '); return 1; } + const entries = parsePending(readFileSync(rest[0], 'utf8')).map((e) => ({ ts: new Date().toISOString(), ...e })); + appendFeedback(FEEDBACK, entries); + out(`Ingested ${entries.length} outcomes`); + return 0; + } + case 'learn': { + let minSupport = 3; + const ms = flag(rest, '--min-support'); + if (ms !== undefined) { + const n = Number(ms); + if (!Number.isInteger(n) || n < 1) { out('error: --min-support must be a positive integer'); return 1; } + minSupport = n; + } + const proposals = mineLearnings(loadFeedback(FEEDBACK), { minSupport }); + const merged = mergeProposals(loadLearnings(LEARNINGS), proposals); + saveLearnings(LEARNINGS, merged); + out(`Mined ${proposals.length} proposals; ${merged.learnings.length} total`); + return 0; + } + case 'learnings': { + out(JSON.stringify(listLearnings(loadLearnings(LEARNINGS), flag(rest, '--status')), null, 2)); + return 0; + } + case 'approve': + case 'reject': { + if (!rest[0]) { out(`usage: yarn review ${cmd} `); return 1; } + const status = cmd === 'approve' ? 'approved' : 'rejected'; + saveLearnings(LEARNINGS, setLearningStatus(loadLearnings(LEARNINGS), rest[0], status)); + out(`${rest[0]} -> ${status}`); + return 0; + } + case 'run': { + const base = flag(rest, '--base'); + const scope = flag(rest, '--scope') || 'single_feature'; + const mode = rest.find((a) => !a.startsWith('--') && a !== base && a !== scope) || 'standard'; + if (!MODES.includes(mode)) { out(`error: unknown mode "${mode}" (use ${MODES.join('/')})`); return 1; } + const { base: b, files } = changedFiles(base); + const diff = execFileSync('git', ['-C', ROOT, 'diff', `${b}...HEAD`], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + const stat = execFileSync('git', ['-C', ROOT, 'diff', '--stat', `${b}...HEAD`], { encoding: 'utf8' }); + const cfg = loadConfig({ configPath: CONFIG, schemaPath: SCHEMA }); + const plan = buildPlan({ files, diffText: diff, linesChanged: linesChangedFromStat(stat), scope }, cfg); + const impact = cfg.index && cfg.index.enabled ? queryImpact(files, loadIndex(cfg), {}) : null; + out(preflightSummary(plan, impact)); + writeFileSync(join(os.tmpdir(), 'review_plan.json'), JSON.stringify({ ...plan, impact }, null, 2)); + if (rest.includes('--no-launch')) { out(`\nwould run: claude -p "/agent-review ${mode}"`); return 0; } + out(`\nlaunching: claude -p "/agent-review ${mode}" ...\n`); + try { execFileSync('claude', ['-p', `/agent-review ${mode}`], { stdio: 'inherit' }); } + catch (e) { + out(`(could not launch claude automatically: ${e.message})`); + out(`Run it manually in Claude Code: /agent-review ${mode}`); + } + return 0; + } + case 'help': + case undefined: + out(USAGE); + return 0; + default: + out(`unknown command: ${cmd}\n\n${USAGE}`); + return 1; + } +} + +if (require.main === module) { + try { process.exit(main(process.argv.slice(2))); } + catch (e) { process.stderr.write(`error: ${e.message}\n`); process.exit(1); } +} + +module.exports = { main }; diff --git a/.claude/review/config.schema.json b/.claude/review/config.schema.json new file mode 100644 index 0000000000..9c950e0cb6 --- /dev/null +++ b/.claude/review/config.schema.json @@ -0,0 +1,134 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mpdx.org/review/config.schema.json", + "title": "MPDX Agent-Review Config", + "type": "object", + "additionalProperties": false, + "required": ["version", "profile", "risk", "agents", "excluded_paths"], + "properties": { + "version": { "type": "integer", "enum": [1] }, + "profile": { "type": "string", "enum": ["chill", "standard", "assertive"] }, + "risk": { + "type": "object", + "additionalProperties": false, + "required": ["patterns", "volume_multiplier", "scope_multiplier", "special", "levels"], + "properties": { + "patterns": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["glob", "points"], + "properties": { + "glob": { "type": "string" }, + "points": { "type": "integer", "minimum": 0 }, + "tier": { "type": "string", "enum": ["critical", "high", "medium", "low"] } + } + } + }, + "volume_multiplier": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["upTo", "points"], + "properties": { + "upTo": { "type": ["integer", "null"] }, + "points": { "type": "integer", "minimum": 0 } + } + } + }, + "scope_multiplier": { "type": "object", "additionalProperties": { "type": "number" } }, + "special": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["when", "points"], + "properties": { + "when": { "type": "string" }, + "points": { "type": "integer", "minimum": 0 }, + "packages": { "type": "array", "items": { "type": "string" } } + } + } + }, + "levels": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["range", "level", "reviewer"], + "properties": { + "range": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "type": ["integer", "null"] } }, + "level": { "type": "string", "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"] }, + "reviewer": { "type": "string" } + } + } + } + } + }, + "agents": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { "type": "string" }, + "enabled": { "type": "boolean" }, + "always": { "type": "boolean" }, + "model": { "type": "string", "enum": ["smart", "opus", "sonnet", "haiku"] }, + "triggers": { + "type": "object", + "additionalProperties": false, + "properties": { + "paths": { "type": "array", "items": { "type": "string" } }, + "content": { "type": "array", "items": { "type": "string" } } + } + }, + "rules": { "type": "array", "items": { "type": "string", "pattern": "^rules/[A-Za-z0-9._-]+\\.md$" } } + } + } + }, + "path_rules": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["paths", "rules"], + "properties": { + "paths": { "type": "array", "items": { "type": "string" } }, + "rules": { "type": "array", "items": { "type": "string", "pattern": "^rules/[A-Za-z0-9._-]+\\.md$" } } + } + } + }, + "excluded_paths": { "type": "array", "items": { "type": "string" } }, + "index": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "path": { "type": "string" }, + "roots": { "type": "array", "items": { "type": "string" } }, + "aliases": { "type": "array", "items": { "type": "string" } }, + "extensions": { "type": "array", "items": { "type": "string" } } + } + }, + "learning": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "path": { "type": "string" }, + "approval_required": { "type": "boolean" }, + "min_support": { "type": "integer", "minimum": 1 }, + "scope": { "type": "string", "enum": ["local", "global"] } + } + }, + "enforcement": { + "type": "object", + "additionalProperties": false, + "properties": { "mode": { "type": "string", "enum": ["warn", "block"] } } + } + } +} diff --git a/.claude/review/config.yml b/.claude/review/config.yml new file mode 100644 index 0000000000..8ec48ec985 --- /dev/null +++ b/.claude/review/config.yml @@ -0,0 +1,160 @@ +version: 1 + +# Global severity/verbosity dial (CodeRabbit `profile` model). +# chill → fewer, high-confidence findings; raises consensus threshold +# standard → current behavior (default) +# assertive → more findings, lower threshold, may be nitpicky +profile: standard + +# ── Risk scoring (migrated from code-review.md) ─────────────────────────────── +risk: + # File-pattern contributions. `tier` is descriptive; `points` drives the score. + patterns: + - { glob: "pages/api/auth/**", points: 3, tier: critical } + - { glob: "pages/api/graphql-rest.page.ts", points: 3, tier: critical } + - { glob: "pages/api/Schema/index.ts", points: 3, tier: critical } + - { glob: "src/lib/apollo/{client,link,cache,ssrClient}.ts", points: 3, tier: critical } + - { glob: "next.config.{js,ts}", points: 3, tier: critical } + - { glob: ".github/workflows/**", points: 3, tier: critical } + # Review-harness integrity (commands/rules/settings) is critical; engine source is ordinary code. + - { glob: ".claude/commands/**", points: 3, tier: critical } + - { glob: ".claude/settings.json", points: 3, tier: critical } + - { glob: ".claude/rules/**", points: 2, tier: high } + - { glob: ".claude/review/engine/**", points: 1, tier: medium } + - { glob: "pages/api/Schema/**/*.{ts,graphql}", points: 2, tier: high } + - { glob: "src/components/Shared/**", points: 2, tier: high } + - { glob: "src/components/**/*.graphql", points: 2, tier: high } + - { glob: "src/components/**/*.{ts,tsx}", points: 1, tier: medium } + - { glob: "src/hooks/**/*.ts", points: 1, tier: medium } + - { glob: "pages/**/*.page.tsx", points: 1, tier: medium } + # Low-risk overrides (explicit 0 points) + - { glob: "**/*.test.{ts,tsx}", points: 0, tier: low } + - { glob: "public/locales/**", points: 0, tier: low } + - { glob: "**/*.snap", points: 0, tier: low } + + # Change-volume → points (lines changed across the diff). + volume_multiplier: + - { upTo: 50, points: 0 } + - { upTo: 200, points: 1 } + - { upTo: 500, points: 2 } + - { upTo: 1000, points: 3 } + - { upTo: null, points: 4 } # 1000+ + + # Scope multiplier applied to the pattern+volume subtotal. + scope_multiplier: + single_file: 1.0 + single_feature: 1.0 + multi_feature: 1.3 + cross_cutting: 1.7 + core_infra: 2.0 + + # Special detections (from code-review.md "Special Pattern Detection"). + special: + - { when: new_dependency, points: 2 } + - { when: critical_pkg_update, points: 3, + packages: [next, react, "@apollo/client", "@mui/material", formik, next-auth, typescript, graphql-codegen] } + - { when: lockfile_only_change, points: 1 } + - { when: graphql_without_codegen_check, points: 2 } + - { when: next_config_security_change, points: 2 } # rewrites/headers/CSP/image domains + - { when: apollo_cache_typepolicy_change, points: 2 } + + # Score → risk level + required reviewer (from code-review.md classification). + levels: + - { range: [0, 3], level: LOW, reviewer: entry } + - { range: [4, 6], level: MEDIUM, reviewer: entry } + - { range: [7, 9], level: HIGH, reviewer: experienced } + - { range: [10, null], level: CRITICAL, reviewer: "Caleb Cox (senior)" } + +# ── Agents (the 7 specialists, declaratively) ───────────────────────────────── +agents: + - id: security + enabled: true + model: smart # smart | opus | sonnet | haiku + always: false # if true, runs regardless of triggers + triggers: + paths: ["pages/api/**", "src/lib/apollo/{link,client,ssrClient}.ts", + "next.config.{js,ts}", "pages/_app.page.tsx", ".github/workflows/**", + ".claude/commands/**", ".claude/rules/**", ".claude/settings.json"] + content: ["process.env.", "dangerouslySetInnerHTML", "router.push("] + rules: ["rules/security.md"] + + - id: architecture + enabled: true + always: true + rules: ["rules/architecture.md"] + + - id: data-integrity + enabled: true + triggers: + paths: ["pages/api/Schema/**/*.{ts,graphql}", "src/lib/apollo/cache.ts", + "src/components/**/*.graphql", "src/graphql/rootFields.generated.ts"] + content: ["mutation", "optimisticResponse", "refetchQueries", "cache.modify", "__typename", + "first:", "after:", "pageInfo", "nodes"] + rules: ["rules/data-integrity.md"] + + - id: testing + enabled: true + always: true + rules: ["rules/testing.md"] + + - id: ux + enabled: true + triggers: + paths: ["src/components/**/*.tsx", "pages/**/*.page.tsx", "src/theme.ts", "src/theme/**"] + content: [" l.kind === 'suppress').map((l) => l.signature)); + const kept = []; + const suppressed = []; + for (const f of findings) { + if (sigs.has(f.signature)) suppressed.push(f); + else kept.push(f); + } + return { kept, suppressed }; +} + +function rulesFromLearnings(approved) { + return (approved || []) + .filter((l) => l.kind === 'rule') + .map((l) => ({ paths: l.paths || [], ruleText: l.ruleText || l.example || '', agent: l.agent })); +} + +module.exports = { filterFindings, rulesFromLearnings }; diff --git a/.claude/review/engine/applyLearnings.test.cjs b/.claude/review/engine/applyLearnings.test.cjs new file mode 100644 index 0000000000..da5fedab90 --- /dev/null +++ b/.claude/review/engine/applyLearnings.test.cjs @@ -0,0 +1,25 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { filterFindings, rulesFromLearnings } = require('./applyLearnings.cjs'); + +const approved = [ + { kind: 'suppress', signature: 'sig-bad', paths: ['src/components/**'] }, + { kind: 'rule', agent: 'ux', paths: ['src/components/**'], ruleText: 'Always localize labels' }, +]; + +test('filterFindings suppresses matching signatures, keeps others', () => { + const findings = [ + { id: 'f1', signature: 'sig-bad', file: 'src/components/A.tsx' }, + { id: 'f2', signature: 'sig-ok', file: 'src/components/B.tsx' }, + ]; + const { kept, suppressed } = filterFindings(findings, approved); + assert.deepEqual(kept.map((f) => f.id), ['f2']); + assert.deepEqual(suppressed.map((f) => f.id), ['f1']); +}); + +test('rulesFromLearnings maps approved rule learnings', () => { + const rules = rulesFromLearnings(approved); + assert.equal(rules.length, 1); + assert.deepEqual(rules[0], { paths: ['src/components/**'], ruleText: 'Always localize labels', agent: 'ux' }); +}); diff --git a/.claude/review/engine/args.cjs b/.claude/review/engine/args.cjs new file mode 100644 index 0000000000..9c65139358 --- /dev/null +++ b/.claude/review/engine/args.cjs @@ -0,0 +1,22 @@ +'use strict'; +// Single shared CLI argument parser used by every engine entry point (no per-module drift). +// A `--flag` with no following value, or followed by another `--flag`, is boolean `true`; +// otherwise it consumes the next token as its value. +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i++) { + const tok = argv[i]; + if (!tok.startsWith('--')) continue; + const key = tok.slice(2); + const next = argv[i + 1]; + if (next === undefined || next.startsWith('--')) { + args[key] = true; + } else { + args[key] = next; + i++; + } + } + return args; +} + +module.exports = { parseArgs }; diff --git a/.claude/review/engine/buildGraph.cjs b/.claude/review/engine/buildGraph.cjs new file mode 100644 index 0000000000..dc04ab9ac1 --- /dev/null +++ b/.claude/review/engine/buildGraph.cjs @@ -0,0 +1,45 @@ +'use strict'; +const { resolveImport } = require('./resolveImport.cjs'); + +const PATTERNS = [ + /\bimport\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g, + /\bimport\s*['"]([^'"]+)['"]/g, + /\bexport\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g, + /\brequire\(\s*['"]([^'"]+)['"]\s*\)/g, + /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g, +]; + +function extractSpecifiers(text) { + const specs = new Set(); + for (const re of PATTERNS) { + re.lastIndex = 0; + let m; + while ((m = re.exec(text)) !== null) specs.add(m[1]); + } + return [...specs]; +} + +function buildGraph(files, readFile, fileSet, opts = {}) { + const imports = {}; + const importedBy = {}; + for (const file of files) { + let text; + try { + text = readFile(file); + } catch { + text = ''; + } + const targets = new Set(); + for (const spec of extractSpecifiers(text)) { + const resolved = resolveImport(file, spec, fileSet, opts); + if (resolved && resolved !== file) targets.add(resolved); + } + imports[file] = [...targets]; + for (const t of targets) { + (importedBy[t] = importedBy[t] || []).push(file); + } + } + return { imports, importedBy }; +} + +module.exports = { buildGraph, extractSpecifiers }; diff --git a/.claude/review/engine/buildGraph.test.cjs b/.claude/review/engine/buildGraph.test.cjs new file mode 100644 index 0000000000..79b537c298 --- /dev/null +++ b/.claude/review/engine/buildGraph.test.cjs @@ -0,0 +1,33 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildGraph, extractSpecifiers } = require('./buildGraph.cjs'); + +test('extractSpecifiers finds import/export/require/dynamic specifiers', () => { + const text = ` + import a from './a'; + import { b } from 'src/b'; + export { c } from './c'; + const d = require('./d'); + const e = await import('./e'); + import 'side-effect'; + `; + const specs = extractSpecifiers(text).sort(); + assert.deepEqual(specs, ['./a', './c', './d', './e', 'side-effect', 'src/b'].sort()); +}); + +test('buildGraph builds imports + importedBy, drops externals, dedupes', () => { + const files = ['src/a.tsx', 'src/b.ts', 'src/c.ts']; + const fileSet = new Set(files); + const contents = { + 'src/a.tsx': "import { b } from 'src/b';\nimport x from 'react';\nimport { b2 } from './b';", + 'src/b.ts': "import { c } from './c';", + 'src/c.ts': "export const c = 1;", + }; + const graph = buildGraph(files, (f) => contents[f], fileSet); + assert.deepEqual(graph.imports['src/a.tsx'], ['src/b.ts']); // react dropped, dup b deduped + assert.deepEqual(graph.imports['src/b.ts'], ['src/c.ts']); + assert.deepEqual(graph.imports['src/c.ts'], []); + assert.deepEqual(graph.importedBy['src/b.ts'], ['src/a.tsx']); + assert.deepEqual(graph.importedBy['src/c.ts'], ['src/b.ts']); +}); diff --git a/.claude/review/engine/cli.test.cjs b/.claude/review/engine/cli.test.cjs new file mode 100644 index 0000000000..cdc59345b2 --- /dev/null +++ b/.claude/review/engine/cli.test.cjs @@ -0,0 +1,31 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { main } = require('../cli.cjs'); + +function run(args) { + const orig = process.stdout.write.bind(process.stdout); + let s = ''; + process.stdout.write = (x) => { s += x; return true; }; + let code; + try { code = main(args); } finally { process.stdout.write = orig; } + return { code, s }; +} + +test('help returns 0 and prints usage', () => { + const { code, s } = run(['help']); + assert.equal(code, 0); + assert.match(s, /usage: yarn review/); +}); + +test('no command prints usage', () => { + const { code, s } = run([]); + assert.equal(code, 0); + assert.match(s, /usage: yarn review/); +}); + +test('unknown command returns 1', () => { + const { code, s } = run(['definitely-not-a-command']); + assert.equal(code, 1); + assert.match(s, /unknown command/); +}); diff --git a/.claude/review/engine/cliCommands.cjs b/.claude/review/engine/cliCommands.cjs new file mode 100644 index 0000000000..969e369cf4 --- /dev/null +++ b/.claude/review/engine/cliCommands.cjs @@ -0,0 +1,33 @@ +'use strict'; + +function setLearningStatus(learnings, id, status) { + const list = (learnings && learnings.learnings) || []; + if (!list.some((l) => l.id === id)) throw new Error(`Learning not found: ${id}`); + return { ...learnings, learnings: list.map((l) => (l.id === id ? { ...l, status } : l)) }; +} + +function listLearnings(learnings, statusFilter) { + const list = (learnings && learnings.learnings) || []; + return list + .filter((l) => !statusFilter || l.status === statusFilter) + .map((l) => ({ id: l.id, kind: l.kind, status: l.status, support: l.support, paths: l.paths || [], example: l.example || l.ruleText || '' })); +} + +function preflightSummary(plan, impact) { + const lines = []; + lines.push(`profile: ${plan.profile}`); + const r = plan.risk; + lines.push(`risk: ${r.score} ${r.level} (reviewer: ${r.reviewer})`); + if (r.special && r.special.length) lines.push(`special: ${r.special.join(', ')}`); + lines.push('agents:'); + for (const a of plan.agents) lines.push(` - ${a.id} [${a.matchedBy}]`); + if (impact) { + lines.push(`impact: blastRadius ${impact.blastRadius}${impact.truncated ? ' (truncated)' : ''}`); + for (const t of (impact.topImpacted || []).filter((x) => x.dependentCount > 0).slice(0, 5)) { + lines.push(` - ${t.dependentCount} dependents: ${t.file}`); + } + } + return lines.join('\n'); +} + +module.exports = { setLearningStatus, listLearnings, preflightSummary }; diff --git a/.claude/review/engine/cliCommands.test.cjs b/.claude/review/engine/cliCommands.test.cjs new file mode 100644 index 0000000000..967b6464ba --- /dev/null +++ b/.claude/review/engine/cliCommands.test.cjs @@ -0,0 +1,35 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { setLearningStatus, listLearnings, preflightSummary } = require('./cliCommands.cjs'); + +const learnings = { version: 1, learnings: [ + { id: 'L-a', kind: 'suppress', status: 'proposed', support: 3, paths: ['src/**'], example: 'x' }, + { id: 'L-b', kind: 'rule', status: 'approved', support: 4, paths: ['pages/**'], ruleText: 'y' }, +] }; + +test('setLearningStatus flips target, preserves others, no mutation', () => { + const updated = setLearningStatus(learnings, 'L-a', 'approved'); + assert.equal(updated.learnings.find((l) => l.id === 'L-a').status, 'approved'); + assert.equal(updated.learnings.find((l) => l.id === 'L-b').status, 'approved'); + assert.equal(learnings.learnings[0].status, 'proposed'); // original untouched +}); + +test('setLearningStatus throws on unknown id', () => { + assert.throws(() => setLearningStatus(learnings, 'nope', 'approved'), /not found/); +}); + +test('listLearnings filters by status', () => { + assert.deepEqual(listLearnings(learnings, 'approved').map((r) => r.id), ['L-b']); + assert.equal(listLearnings(learnings).length, 2); +}); + +test('preflightSummary includes risk, reviewer, agents, blast radius', () => { + const plan = { profile: 'standard', risk: { score: 46, level: 'CRITICAL', reviewer: 'Caleb Cox', special: [] }, agents: [{ id: 'financial', matchedBy: 'path:src/components/HrTools/**' }] }; + const impact = { blastRadius: 166, truncated: false, topImpacted: [{ file: 'src/x.tsx', dependentCount: 29 }] }; + const s = preflightSummary(plan, impact); + assert.match(s, /CRITICAL/); + assert.match(s, /Caleb Cox/); + assert.match(s, /financial/); + assert.match(s, /blastRadius 166/); +}); diff --git a/.claude/review/engine/detectSpecial.cjs b/.claude/review/engine/detectSpecial.cjs new file mode 100644 index 0000000000..aa082d16f2 --- /dev/null +++ b/.claude/review/engine/detectSpecial.cjs @@ -0,0 +1,48 @@ +'use strict'; + +function escapeRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function detectSpecial(diffText, changedFiles, config) { + const found = new Set(); + const special = (config.risk && config.risk.special) || []; + const pkgEntry = special.find((s) => s.when === 'critical_pkg_update'); + const pkgs = (pkgEntry && pkgEntry.packages) || []; + + const pkgChanged = changedFiles.includes('package.json'); + const lockChanged = changedFiles.some((f) => f.endsWith('yarn.lock')); + + if (pkgChanged && /^\+\s*"[^"]+":\s*"[^"]+"/m.test(diffText)) found.add('new_dependency'); + + if (pkgChanged) { + for (const p of pkgs) { + if (new RegExp(`^\\+\\s*"${escapeRe(p)}":`, 'm').test(diffText)) { + found.add('critical_pkg_update'); + break; + } + } + } + + if (lockChanged && !pkgChanged) found.add('lockfile_only_change'); + + if (changedFiles.some((f) => f.endsWith('.graphql'))) found.add('graphql_without_codegen_check'); + + if ( + changedFiles.some((f) => /next\.config\.(js|ts)$/.test(f)) && + /(headers|content-security|csp|rewrites|images|domains)/i.test(diffText) + ) { + found.add('next_config_security_change'); + } + + if ( + changedFiles.some((f) => /apollo\/cache\.ts$/.test(f)) && + /(typePolicies|merge\s*[:(])/.test(diffText) + ) { + found.add('apollo_cache_typepolicy_change'); + } + + return [...found]; +} + +module.exports = { detectSpecial }; diff --git a/.claude/review/engine/detectSpecial.test.cjs b/.claude/review/engine/detectSpecial.test.cjs new file mode 100644 index 0000000000..7d18d7ffe8 --- /dev/null +++ b/.claude/review/engine/detectSpecial.test.cjs @@ -0,0 +1,32 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { detectSpecial } = require('./detectSpecial.cjs'); + +const config = { + risk: { special: [{ when: 'critical_pkg_update', points: 3, packages: ['next', '@apollo/client'] }] }, +}; + +test('detects new dependency added to package.json', () => { + assert.deepEqual(detectSpecial('+ "lodash": "^4.17.21",', ['package.json'], config), ['new_dependency']); +}); + +test('detects critical package update', () => { + const found = detectSpecial('+ "@apollo/client": "^4.0.0",', ['package.json'], config); + assert.ok(found.includes('critical_pkg_update')); +}); + +test('detects lockfile-only change', () => { + assert.deepEqual(detectSpecial('+ some lock line', ['yarn.lock'], config), ['lockfile_only_change']); +}); + +test('detects graphql change and next.config security change', () => { + const found = detectSpecial('+ headers: [...]', ['next.config.ts', 'src/components/Foo/Foo.graphql'], config); + assert.ok(found.includes('graphql_without_codegen_check')); + assert.ok(found.includes('next_config_security_change')); +}); + +test('detects apollo cache typePolicies change', () => { + const found = detectSpecial('+ typePolicies: { Contact: {} }', ['src/lib/apollo/cache.ts'], config); + assert.ok(found.includes('apollo_cache_typepolicy_change')); +}); diff --git a/.claude/review/engine/findingSignature.cjs b/.claude/review/engine/findingSignature.cjs new file mode 100644 index 0000000000..985dc69ea1 --- /dev/null +++ b/.claude/review/engine/findingSignature.cjs @@ -0,0 +1,28 @@ +'use strict'; +const { createHash } = require('node:crypto'); + +function normalizeMessage(msg) { + return String(msg || '') + .toLowerCase() + .replace(/['"`][^'"`]*['"`]/g, ' ') // strip quoted identifiers + .replace(/\d+/g, ' ') // strip digits + .replace(/\s+/g, ' ') + .trim(); +} + +function topDir(file) { + const parts = String(file || '').split('/').filter(Boolean); + return parts.slice(0, 2).join('/'); +} + +function signature(finding) { + const key = [ + finding.agent || '', + finding.category || '', + normalizeMessage(finding.message), + topDir(finding.file), + ].join('|'); + return createHash('sha1').update(key).digest('hex').slice(0, 12); +} + +module.exports = { signature, normalizeMessage, topDir }; diff --git a/.claude/review/engine/findingSignature.test.cjs b/.claude/review/engine/findingSignature.test.cjs new file mode 100644 index 0000000000..ea9abcecf4 --- /dev/null +++ b/.claude/review/engine/findingSignature.test.cjs @@ -0,0 +1,28 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { signature, normalizeMessage, topDir } = require('./findingSignature.cjs'); + +test('normalizeMessage strips digits and quoted identifiers', () => { + assert.equal( + normalizeMessage("Missing id in 'ContactDetails' query line 42"), + normalizeMessage('Missing id in "TaskList" query line 99'), + ); +}); + +test('topDir returns first two path segments', () => { + assert.equal(topDir('src/components/Foo/Bar.tsx'), 'src/components'); + assert.equal(topDir('pages/api/x.ts'), 'pages/api'); +}); + +test('signature is equal for findings differing only by identifier/line', () => { + const a = { agent: 'data-integrity', category: 'graphql', file: 'src/components/Foo/A.tsx', message: "Missing id in 'ContactDetails' query at line 12" }; + const b = { agent: 'data-integrity', category: 'graphql', file: 'src/components/Foo/B.tsx', message: "Missing id in 'TaskList' query at line 88" }; + assert.equal(signature(a), signature(b)); +}); + +test('signature differs when agent differs', () => { + const a = { agent: 'data-integrity', category: 'graphql', file: 'src/x/A.tsx', message: 'same' }; + const b = { agent: 'security', category: 'graphql', file: 'src/x/A.tsx', message: 'same' }; + assert.notEqual(signature(a), signature(b)); +}); diff --git a/.claude/review/engine/fixes.test.cjs b/.claude/review/engine/fixes.test.cjs new file mode 100644 index 0000000000..ac249de4b9 --- /dev/null +++ b/.claude/review/engine/fixes.test.cjs @@ -0,0 +1,101 @@ +'use strict'; +// Regression tests for the dogfood-review fixes (PR #1858). +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { writeFileSync, mkdtempSync, rmSync } = require('node:fs'); +const { join } = require('node:path'); +const os = require('node:os'); +const { parseArgs } = require('./plan.cjs'); +const { posInt } = require('./impact.cjs'); +const { selectAgents } = require('./selectAgents.cjs'); +const { detectSpecial } = require('./detectSpecial.cjs'); +const { loadFeedback } = require('./learningsStore.cjs'); +const { loadConfig } = require('./loadConfig.cjs'); + +test('#6 plan.parseArgs: a boolean flag does not swallow the next token', () => { + const a = parseArgs(['--verbose', '--config', 'c.yml']); + assert.equal(a.verbose, true); + assert.equal(a.config, 'c.yml'); +}); + +test('#11 impact.posInt: rejects NaN / non-positive, accepts default + valid', () => { + assert.equal(posInt(undefined, 3), 3); + assert.equal(posInt('5', 3), 5); + assert.throws(() => posInt('abc', 3), /positive integer/); + assert.throws(() => posInt('0', 3), /positive integer/); +}); + +test('#2 selectAgents: content triggers ignore keywords inside markdown/excluded files', () => { + const config = { + excluded_paths: ['.claude/review/rules/**'], + agents: [{ id: 'financial', triggers: { content: ['amount'] } }], + }; + const docDiff = [ + 'diff --git a/.claude/review/rules/financial.md b/.claude/review/rules/financial.md', + '+ flag any amount that is float-summed', + 'diff --git a/docs/notes.md b/docs/notes.md', + '+ the amount here is prose', + ].join('\n'); + // "amount" appears only in a .md rule doc + a doc file → financial must NOT be selected + assert.deepEqual(selectAgents({ files: ['.claude/review/rules/financial.md', 'docs/notes.md'], diffText: docDiff }, config), []); + // "amount" in real code DOES select it + const codeDiff = 'diff --git a/src/x.ts b/src/x.ts\n+ const amount = 1;'; + assert.ok(selectAgents({ files: ['src/x.ts'], diffText: codeDiff }, config).some((a) => a.id === 'financial')); +}); + +test("#2 selectAgents: the reviewer's own config.yml trigger vocabulary does not self-select agents", () => { + const config = { + excluded_paths: [], + agents: [{ id: 'financial', triggers: { content: ['amount'] } }], + }; + // config.yml legitimately contains the word "amount" as a trigger definition — must NOT match. + const diff = 'diff --git a/.claude/review/config.yml b/.claude/review/config.yml\n+ content: ["amount", "currency"]'; + assert.deepEqual(selectAgents({ files: ['.claude/review/config.yml'], diffText: diff }, config), []); +}); + +test('#22 detectSpecial: negative/boundary cases do not over-flag', () => { + const config = { risk: { special: [{ when: 'critical_pkg_update', points: 3, packages: ['next'] }] } }; + assert.ok(!detectSpecial('- "lodash": "^4",', ['package.json'], config).includes('new_dependency')); + assert.ok(!detectSpecial('+ x', ['yarn.lock', 'package.json'], config).includes('lockfile_only_change')); + assert.ok(!detectSpecial('+ const x = 1;', ['next.config.ts'], config).includes('next_config_security_change')); +}); + +test('#19 learningsStore.loadFeedback: skips malformed JSONL lines', () => { + const dir = mkdtempSync(join(os.tmpdir(), 'fb-')); + const p = join(dir, 'feedback.jsonl'); + writeFileSync(p, '{"a":1}\nNOT JSON\n{"b":2}\n'); + assert.deepEqual(loadFeedback(p), [{ a: 1 }, { b: 2 }]); + rmSync(dir, { recursive: true, force: true }); +}); + +test('#12 loadConfig: throws a friendly error on an invalid config', () => { + const dir = mkdtempSync(join(os.tmpdir(), 'cfg-')); + const cfgPath = join(dir, 'config.yml'); + const schemaPath = join(__dirname, '..', 'config.schema.json'); + writeFileSync(cfgPath, 'version: 1\nprofile: nope\n'); // invalid enum + missing required keys + assert.throws(() => loadConfig({ configPath: cfgPath, schemaPath }), /Invalid review config/); + rmSync(dir, { recursive: true, force: true }); +}); + +test('#23 resolveImport: honors config-driven aliases + extensions', () => { + const { resolveImport } = require('./resolveImport.cjs'); + const fileSet = new Set(['app/foo.mjs', 'lib/bar.ts']); + assert.equal(resolveImport('x.ts', 'app/foo', fileSet, { aliases: ['app/', 'lib/'], exts: ['.mjs', '.ts'] }), 'app/foo.mjs'); + assert.equal(resolveImport('x.ts', 'src/foo', fileSet, { aliases: ['app/'], exts: ['.ts'] }), null); +}); + +test('#23 indexRegex: built from config roots + extensions', () => { + const { indexRegex } = require('./indexStore.cjs'); + const re = indexRegex(['app'], ['.mjs']); + assert.ok(re.test('app/x.mjs')); + assert.ok(!re.test('src/x.ts')); // src not a configured root + assert.ok(!re.test('app/x.ts')); // .ts not a configured extension +}); + +test('#4 shared args.parseArgs: one parser, boolean-flag safe', () => { + const { parseArgs } = require('./args.cjs'); + const a = parseArgs(['--build', '--root', '/r', 'positional', '--max-depth', '2']); + assert.equal(a.build, true); + assert.equal(a.root, '/r'); + assert.equal(a['max-depth'], '2'); +}); diff --git a/.claude/review/engine/impact.cjs b/.claude/review/engine/impact.cjs new file mode 100644 index 0000000000..fa7faa7aff --- /dev/null +++ b/.claude/review/engine/impact.cjs @@ -0,0 +1,30 @@ +'use strict'; +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { parseArgs } = require('./args.cjs'); +const { loadOrBuildIndex, gitHead, listRepoFiles } = require('./indexStore.cjs'); +const { queryImpact } = require('./queryImpact.cjs'); + +function posInt(value, def) { + if (value === undefined || value === true) return def; + const n = Number(value); + if (!Number.isInteger(n) || n < 1) throw new Error(`expected a positive integer, got "${value}"`); + return n; +} + +if (require.main === module) { + try { + const a = parseArgs(process.argv.slice(2)); + const repoRoot = typeof a.root === 'string' ? a.root : process.cwd(); + const indexPath = typeof a.index === 'string' ? a.index : join(repoRoot, '.claude/review/index'); + const graph = loadOrBuildIndex({ repoRoot, indexPath, head: gitHead(repoRoot), files: listRepoFiles(repoRoot) }); + const changed = readFileSync(a.changed, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean); + const opts = { maxDepth: posInt(a['max-depth'], 3), maxNodes: posInt(a['max-nodes'], 200) }; + process.stdout.write(JSON.stringify(queryImpact(changed, graph, opts), null, 2) + '\n'); + } catch (e) { + process.stderr.write(`error: ${e.message}\n`); + process.exit(1); + } +} + +module.exports = { parseArgs, posInt }; diff --git a/.claude/review/engine/impact.test.cjs b/.claude/review/engine/impact.test.cjs new file mode 100644 index 0000000000..ba02b88a88 --- /dev/null +++ b/.claude/review/engine/impact.test.cjs @@ -0,0 +1,17 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { parseArgs } = require('./impact.cjs'); + +test('parseArgs reads --flag value pairs', () => { + const a = parseArgs(['--root', '/r', '--changed', '/c.txt', '--max-depth', '2']); + assert.equal(a.root, '/r'); + assert.equal(a.changed, '/c.txt'); + assert.equal(a['max-depth'], '2'); +}); + +test('parseArgs ignores non-flag tokens', () => { + const a = parseArgs(['junk', '--root', '/r']); + assert.equal(a.root, '/r'); + assert.equal(a.junk, undefined); +}); diff --git a/.claude/review/engine/indexStore.cjs b/.claude/review/engine/indexStore.cjs new file mode 100644 index 0000000000..3c6843a0e5 --- /dev/null +++ b/.claude/review/engine/indexStore.cjs @@ -0,0 +1,62 @@ +'use strict'; +const { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } = require('node:fs'); +const { join } = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { buildGraph } = require('./buildGraph.cjs'); +const { DEFAULT_EXTS } = require('./resolveImport.cjs'); +const { parseArgs } = require('./args.cjs'); + +// Which files to index, by repo-root directory. Override per-repo via config `index.roots`. +const DEFAULT_ROOTS = ['src', 'pages', '__tests__']; + +function indexRegex(roots, exts) { + const r = (roots && roots.length ? roots : DEFAULT_ROOTS) + .map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|'); + const e = (exts && exts.length ? exts : DEFAULT_EXTS) + .map((x) => x.replace(/^\./, '').replace(/\./g, '\\.')) + .join('|'); + return new RegExp(`^(${r})\\/.*\\.(${e})$`); +} + +function gitHead(repoRoot) { + return execFileSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); +} + +function listRepoFiles(repoRoot, opts = {}) { + const re = indexRegex(opts.roots, opts.exts); + const out = execFileSync('git', ['-C', repoRoot, 'ls-files'], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + return out.split('\n').map((s) => s.trim()).filter((f) => re.test(f)); +} + +function loadOrBuildIndex({ repoRoot, indexPath, head, files, opts = {} }) { + const graphFile = join(indexPath, 'graph.json'); + if (existsSync(graphFile)) { + try { + const cached = JSON.parse(readFileSync(graphFile, 'utf8')); + if (cached.head === head) return cached; + } catch { + /* fall through to rebuild */ + } + } + const fileSet = new Set(files); + const { imports, importedBy } = buildGraph(files, (f) => readFileSync(join(repoRoot, f), 'utf8'), fileSet, opts); + const graph = { version: 1, head, fileCount: files.length, imports, importedBy }; + mkdirSync(indexPath, { recursive: true }); + writeFileSync(graphFile, JSON.stringify(graph)); + return graph; +} + +if (require.main === module) { + const a = parseArgs(process.argv.slice(2)); + const repoRoot = typeof a.root === 'string' ? a.root : process.cwd(); + const indexPath = typeof a.index === 'string' ? a.index : join(repoRoot, '.claude/review/index'); + if (a.build || a.force) { + const gf = join(indexPath, 'graph.json'); + if (existsSync(gf)) rmSync(gf); + } + const graph = loadOrBuildIndex({ repoRoot, indexPath, head: gitHead(repoRoot), files: listRepoFiles(repoRoot) }); + process.stdout.write(`Indexed ${graph.fileCount} files; ${Object.keys(graph.importedBy).length} have dependents. head=${graph.head}\n`); +} + +module.exports = { loadOrBuildIndex, gitHead, listRepoFiles, indexRegex, DEFAULT_ROOTS }; diff --git a/.claude/review/engine/indexStore.test.cjs b/.claude/review/engine/indexStore.test.cjs new file mode 100644 index 0000000000..a34894b140 --- /dev/null +++ b/.claude/review/engine/indexStore.test.cjs @@ -0,0 +1,53 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } = require('node:fs'); +const { join } = require('node:path'); +const os = require('node:os'); +const { loadOrBuildIndex } = require('./indexStore.cjs'); + +function tmpRepo() { + const root = mkdtempSync(join(os.tmpdir(), 'idxtest-')); + mkdirSync(join(root, 'src'), { recursive: true }); + writeFileSync(join(root, 'src/a.ts'), "import { b } from './b';"); + writeFileSync(join(root, 'src/b.ts'), 'export const b = 1;'); + return root; +} + +test('builds graph and writes graph.json', () => { + const root = tmpRepo(); + const indexPath = join(root, '.claude/review/index'); + const graph = loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h1', files: ['src/a.ts', 'src/b.ts'] }); + assert.equal(graph.head, 'h1'); + assert.equal(graph.fileCount, 2); + assert.deepEqual(graph.importedBy['src/b.ts'], ['src/a.ts']); + rmSync(root, { recursive: true, force: true }); +}); + +test('reuses cache when head matches (no rebuild)', () => { + const root = tmpRepo(); + const indexPath = join(root, '.claude/review/index'); + loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h1', files: ['src/a.ts', 'src/b.ts'] }); + // tamper the cache with a sentinel; a reuse returns it unchanged, a rebuild drops it + const gf = join(indexPath, 'graph.json'); + const cached = JSON.parse(readFileSync(gf, 'utf8')); + cached.sentinel = 'KEEP'; + writeFileSync(gf, JSON.stringify(cached)); + const again = loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h1', files: ['src/a.ts', 'src/b.ts'] }); + assert.equal(again.sentinel, 'KEEP'); + rmSync(root, { recursive: true, force: true }); +}); + +test('rebuilds when head differs', () => { + const root = tmpRepo(); + const indexPath = join(root, '.claude/review/index'); + loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h1', files: ['src/a.ts', 'src/b.ts'] }); + const gf = join(indexPath, 'graph.json'); + const cached = JSON.parse(readFileSync(gf, 'utf8')); + cached.sentinel = 'KEEP'; + writeFileSync(gf, JSON.stringify(cached)); + const rebuilt = loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h2', files: ['src/a.ts', 'src/b.ts'] }); + assert.equal(rebuilt.sentinel, undefined); + assert.equal(rebuilt.head, 'h2'); + rmSync(root, { recursive: true, force: true }); +}); diff --git a/.claude/review/engine/learningsStore.cjs b/.claude/review/engine/learningsStore.cjs new file mode 100644 index 0000000000..dfd46a2db3 --- /dev/null +++ b/.claude/review/engine/learningsStore.cjs @@ -0,0 +1,98 @@ +'use strict'; +const { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } = require('node:fs'); +const { join, dirname } = require('node:path'); +const YAML = require('yaml'); +const { signature } = require('./findingSignature.cjs'); +const { mineLearnings } = require('./mineLearnings.cjs'); +const { filterFindings, rulesFromLearnings } = require('./applyLearnings.cjs'); + +function mergeProposals(existing, proposals) { + const out = { version: 1, learnings: [...((existing && existing.learnings) || [])] }; + const ids = new Set(out.learnings.map((l) => l.id)); + for (const p of proposals) { + if (!ids.has(p.id)) { + out.learnings.push(p); + ids.add(p.id); + } // existing entries keep their status + } + return out; +} + +function parsePending(yamlText) { + const doc = YAML.parse(yamlText) || {}; + const out = []; + for (const f of doc.findings || []) { + if (f.outcome === 'accepted' || f.outcome === 'dismissed') { + out.push({ + reviewId: doc.reviewId, id: f.id, signature: f.signature, agent: f.agent, + category: f.category, severity: f.severity, file: f.file, message: f.message, outcome: f.outcome, + }); + } + } + return out; +} + +function loadApproved(learnings) { + return ((learnings && learnings.learnings) || []).filter((l) => l.status === 'approved'); +} + +function loadLearnings(path) { + return existsSync(path) ? YAML.parse(readFileSync(path, 'utf8')) || { version: 1, learnings: [] } : { version: 1, learnings: [] }; +} +function saveLearnings(path, obj) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, YAML.stringify(obj)); +} +function loadFeedback(path) { + if (!existsSync(path)) return []; + const out = []; + for (const line of readFileSync(path, 'utf8').split('\n')) { + if (!line.trim()) continue; + try { out.push(JSON.parse(line)); } catch { /* skip malformed line */ } + } + return out; +} +function appendFeedback(path, entries) { + mkdirSync(dirname(path), { recursive: true }); + for (const e of entries) appendFileSync(path, JSON.stringify(e) + '\n'); +} + +module.exports = { mergeProposals, parsePending, loadApproved, loadLearnings, saveLearnings, loadFeedback, appendFeedback }; + +if (require.main === module) { + const { parseArgs } = require('./args.cjs'); + const a = parseArgs(process.argv.slice(2)); + const base = join(process.cwd(), '.claude/review/learnings'); + const feedbackPath = join(base, 'feedback.jsonl'); + const learningsPath = join(base, 'learnings.yml'); + const findingsPath = join(base, 'findings.json'); + + if (a.emit) { + const raw = JSON.parse(readFileSync(a.in, 'utf8')); + const reviewId = typeof a.review === 'string' ? a.review : 'review'; + const findings = (raw.findings || raw).map((f, i) => ({ id: `f${i + 1}`, signature: signature(f), ...f })); + mkdirSync(join(base, 'pending'), { recursive: true }); + writeFileSync(findingsPath, JSON.stringify({ reviewId, findings }, null, 2)); + const pending = { reviewId, findings: findings.map((f) => ({ id: f.id, signature: f.signature, agent: f.agent, category: f.category, severity: f.severity, file: f.file, message: f.message, outcome: '' })) }; + writeFileSync(join(base, 'pending', `${reviewId}.yml`), YAML.stringify(pending)); + process.stdout.write(`Emitted ${findings.length} findings; pending/${reviewId}.yml\n`); + } else if (a.ingest) { + const pendingFile = typeof a.ingest === 'string' ? a.ingest : a.in; + const entries = parsePending(readFileSync(pendingFile, 'utf8')).map((e) => ({ ts: new Date().toISOString(), ...e })); + appendFeedback(feedbackPath, entries); + process.stdout.write(`Ingested ${entries.length} outcomes\n`); + } else if (a.mine) { + const minSupport = typeof a['min-support'] === 'string' ? Number(a['min-support']) : 3; + const proposals = mineLearnings(loadFeedback(feedbackPath), { minSupport }); + const merged = mergeProposals(loadLearnings(learningsPath), proposals); + saveLearnings(learningsPath, merged); + process.stdout.write(`Mined ${proposals.length} proposals; ${merged.learnings.length} total\n`); + } else if (a.rules) { + process.stdout.write(JSON.stringify(rulesFromLearnings(loadApproved(loadLearnings(learningsPath))), null, 2) + '\n'); + } else if (a.filter) { + const raw = JSON.parse(readFileSync(typeof a.in === 'string' ? a.in : findingsPath, 'utf8')); + process.stdout.write(JSON.stringify(filterFindings(raw.findings || raw, loadApproved(loadLearnings(learningsPath))), null, 2) + '\n'); + } else { + process.stdout.write('usage: learningsStore.cjs [--emit --in --review | --ingest | --mine [--min-support N] | --rules | --filter --in ]\n'); + } +} diff --git a/.claude/review/engine/learningsStore.test.cjs b/.claude/review/engine/learningsStore.test.cjs new file mode 100644 index 0000000000..428c802ad3 --- /dev/null +++ b/.claude/review/engine/learningsStore.test.cjs @@ -0,0 +1,34 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { mergeProposals, parsePending, loadApproved } = require('./learningsStore.cjs'); + +test('mergeProposals adds new and preserves existing status', () => { + const existing = { version: 1, learnings: [{ id: 'L-a', kind: 'suppress', status: 'approved' }] }; + const proposals = [{ id: 'L-a', kind: 'suppress', status: 'proposed' }, { id: 'L-b', kind: 'rule', status: 'proposed' }]; + const merged = mergeProposals(existing, proposals); + const a = merged.learnings.find((l) => l.id === 'L-a'); + const b = merged.learnings.find((l) => l.id === 'L-b'); + assert.equal(a.status, 'approved'); // preserved, not reset to proposed + assert.equal(b.status, 'proposed'); // newly added + assert.equal(merged.learnings.length, 2); +}); + +test('parsePending keeps only findings with a filled outcome', () => { + const yamlText = ` +reviewId: r1 +findings: + - { id: f1, signature: s1, agent: ux, file: src/a.tsx, message: m1, outcome: dismissed } + - { id: f2, signature: s2, agent: ux, file: src/b.tsx, message: m2, outcome: "" } + - { id: f3, signature: s3, agent: ux, file: src/c.tsx, message: m3, outcome: accepted } +`; + const entries = parsePending(yamlText); + assert.deepEqual(entries.map((e) => e.id), ['f1', 'f3']); + assert.equal(entries[0].reviewId, 'r1'); + assert.equal(entries[0].outcome, 'dismissed'); +}); + +test('loadApproved filters by status', () => { + const learnings = { version: 1, learnings: [{ id: 'L-a', status: 'approved' }, { id: 'L-b', status: 'proposed' }] }; + assert.deepEqual(loadApproved(learnings).map((l) => l.id), ['L-a']); +}); diff --git a/.claude/review/engine/loadConfig.cjs b/.claude/review/engine/loadConfig.cjs new file mode 100644 index 0000000000..a974438b27 --- /dev/null +++ b/.claude/review/engine/loadConfig.cjs @@ -0,0 +1,30 @@ +'use strict'; +const { readFileSync } = require('node:fs'); +const { parse } = require('yaml'); +const Ajv = require('ajv/dist/2020'); + +function parseConfig(yamlText) { + return parse(yamlText); +} + +function validateConfig(configObj, schemaObj) { + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schemaObj); + const valid = validate(configObj); + const errors = valid + ? [] + : (validate.errors || []).map((e) => `${e.instancePath || '(root)'} ${e.message}`); + return { valid, errors }; +} + +function loadConfig({ configPath, schemaPath }) { + const configObj = parseConfig(readFileSync(configPath, 'utf8')); + const schemaObj = JSON.parse(readFileSync(schemaPath, 'utf8')); + const { valid, errors } = validateConfig(configObj, schemaObj); + if (!valid) { + throw new Error(`Invalid review config (${configPath}):\n- ${errors.join('\n- ')}`); + } + return configObj; +} + +module.exports = { parseConfig, validateConfig, loadConfig }; diff --git a/.claude/review/engine/loadConfig.test.cjs b/.claude/review/engine/loadConfig.test.cjs new file mode 100644 index 0000000000..e4bd61fe87 --- /dev/null +++ b/.claude/review/engine/loadConfig.test.cjs @@ -0,0 +1,36 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { parseConfig, validateConfig } = require('./loadConfig.cjs'); +const schema = require('../config.schema.json'); + +const MINIMAL = ` +version: 1 +profile: standard +risk: + patterns: [{ glob: "src/**", points: 1, tier: medium }] + volume_multiplier: [{ upTo: null, points: 0 }] + scope_multiplier: { single_feature: 1.0 } + special: [] + levels: [{ range: [0, null], level: LOW, reviewer: entry }] +agents: [{ id: standards, always: true }] +excluded_paths: [] +`; + +test('parseConfig parses YAML to an object', () => { + const cfg = parseConfig(MINIMAL); + assert.equal(cfg.version, 1); + assert.equal(cfg.agents[0].id, 'standards'); +}); + +test('validateConfig accepts a valid config', () => { + const { valid, errors } = validateConfig(parseConfig(MINIMAL), schema); + assert.equal(valid, true, errors.join('; ')); +}); + +test('validateConfig rejects a bad profile enum', () => { + const bad = parseConfig(MINIMAL.replace('profile: standard', 'profile: nope')); + const { valid, errors } = validateConfig(bad, schema); + assert.equal(valid, false); + assert.ok(errors.some((e) => e.includes('profile')), errors.join('; ')); +}); diff --git a/.claude/review/engine/mineLearnings.cjs b/.claude/review/engine/mineLearnings.cjs new file mode 100644 index 0000000000..1fc91d1761 --- /dev/null +++ b/.claude/review/engine/mineLearnings.cjs @@ -0,0 +1,37 @@ +'use strict'; +const { topDir } = require('./findingSignature.cjs'); + +function mineLearnings(feedbackEntries, opts = {}) { + const minSupport = opts.minSupport ?? 3; + const groups = new Map(); + for (const e of feedbackEntries) { + if (!e.signature) continue; + if (!groups.has(e.signature)) groups.set(e.signature, []); + groups.get(e.signature).push(e); + } + const proposals = []; + for (const [sig, entries] of groups) { + const total = entries.length; + if (total < minSupport) continue; + const dismissed = entries.filter((e) => e.outcome === 'dismissed').length; + const accepted = entries.filter((e) => e.outcome === 'accepted').length; + const sample = entries[0]; + const base = { + id: `L-${sig}`, + signature: sig, + agent: sample.agent, + category: sample.category, + paths: [`${topDir(sample.file)}/**`], + support: total, + example: sample.message, + }; + if (dismissed / total >= 0.75) { + proposals.push({ ...base, kind: 'suppress', status: 'proposed', rationale: `Dismissed ${dismissed}/${total} times` }); + } else if (accepted / total >= 0.75) { + proposals.push({ ...base, kind: 'rule', status: 'proposed', ruleText: sample.message, rationale: `Accepted ${accepted}/${total} times` }); + } + } + return proposals; +} + +module.exports = { mineLearnings }; diff --git a/.claude/review/engine/mineLearnings.test.cjs b/.claude/review/engine/mineLearnings.test.cjs new file mode 100644 index 0000000000..de27617c99 --- /dev/null +++ b/.claude/review/engine/mineLearnings.test.cjs @@ -0,0 +1,35 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { mineLearnings } = require('./mineLearnings.cjs'); + +function entry(sig, outcome, over = {}) { + return { signature: sig, outcome, agent: 'ux', category: 'i18n', file: 'src/components/Foo/A.tsx', message: 'hardcoded string', ...over }; +} + +test('proposes suppress for >=75% dismissed above support threshold', () => { + const fb = [entry('s1', 'dismissed'), entry('s1', 'dismissed'), entry('s1', 'dismissed'), entry('s1', 'accepted')]; + const p = mineLearnings(fb, { minSupport: 3 }); + assert.equal(p.length, 1); + assert.equal(p[0].kind, 'suppress'); + assert.equal(p[0].signature, 's1'); + assert.equal(p[0].id, 'L-s1'); + assert.deepEqual(p[0].paths, ['src/components/**']); +}); + +test('proposes rule for >=75% accepted', () => { + const fb = [entry('s2', 'accepted'), entry('s2', 'accepted'), entry('s2', 'accepted')]; + const p = mineLearnings(fb, { minSupport: 3 }); + assert.equal(p[0].kind, 'rule'); + assert.equal(typeof p[0].ruleText, 'string'); +}); + +test('no proposal below support threshold', () => { + const fb = [entry('s3', 'dismissed'), entry('s3', 'dismissed')]; + assert.deepEqual(mineLearnings(fb, { minSupport: 3 }), []); +}); + +test('no proposal for mixed outcomes', () => { + const fb = [entry('s4', 'dismissed'), entry('s4', 'accepted'), entry('s4', 'dismissed'), entry('s4', 'accepted')]; + assert.deepEqual(mineLearnings(fb, { minSupport: 3 }), []); +}); diff --git a/.claude/review/engine/plan.cjs b/.claude/review/engine/plan.cjs new file mode 100644 index 0000000000..0c4aa5aac5 --- /dev/null +++ b/.claude/review/engine/plan.cjs @@ -0,0 +1,34 @@ +'use strict'; +const { readFileSync } = require('node:fs'); +const { parseArgs } = require('./args.cjs'); +const { loadConfig } = require('./loadConfig.cjs'); +const { scoreRisk } = require('./scoreRisk.cjs'); +const { selectAgents } = require('./selectAgents.cjs'); +const { resolveRules } = require('./resolveRules.cjs'); +const { detectSpecial } = require('./detectSpecial.cjs'); + +function buildPlan({ files, diffText, linesChanged, scope }, config) { + const special = detectSpecial(diffText, files, config); + const risk = scoreRisk({ files, linesChanged, scope, special }, config); + const selected = selectAgents({ files, diffText }, config); + const agents = selected.map((a) => ({ ...a, rules: resolveRules(a.id, files, config) })); + return { profile: config.profile, risk: { ...risk, special }, agents }; +} + +function linesChangedFromStat(statText) { + const ins = statText.match(/(\d+) insertions?\(\+\)/); + const del = statText.match(/(\d+) deletions?\(-\)/); + return (ins ? Number(ins[1]) : 0) + (del ? Number(del[1]) : 0); +} + +if (require.main === module) { + const a = parseArgs(process.argv.slice(2)); + const config = loadConfig({ configPath: a.config, schemaPath: a.schema }); + const files = readFileSync(a.files, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean); + const diffText = a.diff ? readFileSync(a.diff, 'utf8') : ''; + const linesChanged = a.stat ? linesChangedFromStat(readFileSync(a.stat, 'utf8')) : 0; + const plan = buildPlan({ files, diffText, linesChanged, scope: a.scope || 'single_feature' }, config); + process.stdout.write(JSON.stringify(plan, null, 2) + '\n'); +} + +module.exports = { buildPlan, parseArgs, linesChangedFromStat }; diff --git a/.claude/review/engine/plan.test.cjs b/.claude/review/engine/plan.test.cjs new file mode 100644 index 0000000000..703f6df150 --- /dev/null +++ b/.claude/review/engine/plan.test.cjs @@ -0,0 +1,37 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildPlan } = require('./plan.cjs'); + +const config = { + profile: 'standard', + excluded_paths: ['**/*.snap'], + risk: { + patterns: [{ glob: 'src/components/**/*.{ts,tsx}', points: 1, tier: 'medium' }], + volume_multiplier: [{ upTo: 50, points: 0 }, { upTo: null, points: 4 }], + scope_multiplier: { single_feature: 1.0 }, + special: [], + levels: [{ range: [0, 3], level: 'LOW', reviewer: 'entry' }, { range: [4, null], level: 'HIGH', reviewer: 'exp' }], + }, + agents: [ + { id: 'architecture', always: true, rules: ['rules/architecture.md'] }, + { id: 'ux', triggers: { paths: ['src/components/**/*.tsx'] }, rules: ['rules/ux.md'] }, + ], + path_rules: [{ paths: ['src/components/**/*.tsx'], rules: ['rules/ux.md'] }], +}; + +test('buildPlan assembles risk + agents + resolved rules', () => { + const plan = buildPlan( + { files: ['src/components/Tasks/TaskRow.tsx'], diffText: '+x', linesChanged: 20, scope: 'single_feature' }, + config, + ); + assert.equal(plan.profile, 'standard'); + assert.equal(plan.risk.level, 'LOW'); + const ux = plan.agents.find((a) => a.id === 'ux'); + assert.ok(ux, 'ux selected'); + assert.deepEqual(ux.rules, ['rules/ux.md']); + const arch = plan.agents.find((a) => a.id === 'architecture'); + // path_rules attach to any selected agent (per design spec §4.4): the changed + // .tsx file matches the path_rule, so architecture also resolves rules/ux.md. + assert.deepEqual(arch.rules, ['rules/architecture.md', 'rules/ux.md']); +}); diff --git a/.claude/review/engine/queryImpact.cjs b/.claude/review/engine/queryImpact.cjs new file mode 100644 index 0000000000..a8c5ca910b --- /dev/null +++ b/.claude/review/engine/queryImpact.cjs @@ -0,0 +1,43 @@ +'use strict'; + +function queryImpact(changedFiles, graph, opts = {}) { + const maxDepth = opts.maxDepth ?? 3; + const maxNodes = opts.maxNodes ?? 200; + const importedBy = graph.importedBy || {}; + const changedSet = new Set(changedFiles); + + const directDependents = {}; + for (const f of changedFiles) { + directDependents[f] = (importedBy[f] || []).filter((d) => !changedSet.has(d)); + } + + const visited = new Set(); + let truncated = false; + let frontier = [...changedFiles]; + for (let depth = 0; depth < maxDepth && frontier.length; depth++) { + const next = []; + for (const f of frontier) { + for (const dep of importedBy[f] || []) { + if (changedSet.has(dep) || visited.has(dep)) continue; + if (visited.size >= maxNodes) { + truncated = true; + break; + } + visited.add(dep); + next.push(dep); + } + if (truncated) break; + } + if (truncated) break; + frontier = next; + } + + const transitiveDependents = [...visited]; + const topImpacted = changedFiles + .map((f) => ({ file: f, dependentCount: directDependents[f].length })) + .sort((a, b) => b.dependentCount - a.dependentCount); + + return { directDependents, transitiveDependents, blastRadius: transitiveDependents.length, topImpacted, truncated }; +} + +module.exports = { queryImpact }; diff --git a/.claude/review/engine/queryImpact.test.cjs b/.claude/review/engine/queryImpact.test.cjs new file mode 100644 index 0000000000..9c1a70c6b0 --- /dev/null +++ b/.claude/review/engine/queryImpact.test.cjs @@ -0,0 +1,38 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { queryImpact } = require('./queryImpact.cjs'); + +// chain: a <- b <- c (importedBy[a] = [b], importedBy[b] = [c]) +const graph = { + imports: {}, + importedBy: { 'a.ts': ['b.ts'], 'b.ts': ['c.ts'], 'shared.ts': ['a.ts', 'b.ts'] }, +}; + +test('direct + transitive dependents and blast radius', () => { + const r = queryImpact(['a.ts'], graph, {}); + assert.deepEqual(r.directDependents['a.ts'], ['b.ts']); + assert.deepEqual(r.transitiveDependents.sort(), ['b.ts', 'c.ts']); + assert.equal(r.blastRadius, 2); + assert.equal(r.topImpacted[0].file, 'a.ts'); + assert.equal(r.topImpacted[0].dependentCount, 1); + assert.equal(r.truncated, false); +}); + +test('maxDepth limits traversal', () => { + const r = queryImpact(['a.ts'], graph, { maxDepth: 1 }); + assert.deepEqual(r.transitiveDependents, ['b.ts']); + assert.equal(r.blastRadius, 1); +}); + +test('maxNodes cap sets truncated', () => { + const r = queryImpact(['a.ts'], graph, { maxNodes: 1 }); + assert.equal(r.truncated, true); + assert.equal(r.transitiveDependents.length, 1); +}); + +test('changed files are excluded from their own dependents', () => { + const r = queryImpact(['shared.ts', 'a.ts'], graph, {}); + assert.ok(!r.directDependents['shared.ts'].includes('a.ts')); // a.ts is itself changed + assert.deepEqual(r.directDependents['shared.ts'], ['b.ts']); +}); diff --git a/.claude/review/engine/realConfig.test.cjs b/.claude/review/engine/realConfig.test.cjs new file mode 100644 index 0000000000..94ea236214 --- /dev/null +++ b/.claude/review/engine/realConfig.test.cjs @@ -0,0 +1,30 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { loadConfig } = require('./loadConfig.cjs'); + +const configPath = path.join(__dirname, '../config.yml'); +const schemaPath = path.join(__dirname, '../config.schema.json'); + +test('real config.yml loads and validates', () => { + const cfg = loadConfig({ configPath, schemaPath }); + assert.equal(cfg.version, 1); +}); + +test('real config defines the 7 MPDX agents', () => { + const cfg = loadConfig({ configPath, schemaPath }); + assert.deepEqual( + cfg.agents.map((a) => a.id).sort(), + ['architecture', 'data-integrity', 'financial', 'security', 'standards', 'testing', 'ux'], + ); +}); + +test('real config enables the index layer and reserves inert learning', () => { + const cfg = loadConfig({ configPath, schemaPath }); + assert.equal(cfg.index.enabled, true); + assert.equal(cfg.index.path, '.claude/review/index'); + assert.equal(cfg.learning.enabled, true); + assert.equal(cfg.learning.approval_required, true); + assert.equal(cfg.learning.min_support, 3); +}); diff --git a/.claude/review/engine/resolveImport.cjs b/.claude/review/engine/resolveImport.cjs new file mode 100644 index 0000000000..c2833091d3 --- /dev/null +++ b/.claude/review/engine/resolveImport.cjs @@ -0,0 +1,32 @@ +'use strict'; +const path = require('node:path'); + +// Defaults match a typical TS/Next repo; override per-repo via config `index.aliases`/`index.extensions`. +const DEFAULT_ALIASES = ['src/', 'pages/', '__tests__/']; +const DEFAULT_EXTS = ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.json']; + +function candidates(base, exts = DEFAULT_EXTS) { + const out = [base]; + for (const e of exts) out.push(base + e); + for (const e of exts) out.push(base + '/index' + e); + return out; +} + +function resolveImport(fromFile, spec, fileSet, opts = {}) { + const aliases = opts.aliases && opts.aliases.length ? opts.aliases : DEFAULT_ALIASES; + const exts = opts.exts && opts.exts.length ? opts.exts : DEFAULT_EXTS; + let base; + if (aliases.some((a) => spec === a.replace(/\/$/, '') || spec.startsWith(a))) { + base = spec; // already repo-root-relative (alias roots) + } else if (spec.startsWith('.')) { + base = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec)); + } else { + return null; // bare / external + } + for (const c of candidates(base, exts)) { + if (fileSet.has(c)) return c; + } + return null; +} + +module.exports = { resolveImport, candidates, DEFAULT_ALIASES, DEFAULT_EXTS, EXTS: DEFAULT_EXTS }; diff --git a/.claude/review/engine/resolveImport.test.cjs b/.claude/review/engine/resolveImport.test.cjs new file mode 100644 index 0000000000..bf1523eee1 --- /dev/null +++ b/.claude/review/engine/resolveImport.test.cjs @@ -0,0 +1,33 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveImport } = require('./resolveImport.cjs'); + +const fileSet = new Set([ + 'src/b.ts', + 'src/a/c.ts', + 'src/lib/index.ts', + 'src/d.tsx', + 'pages/x.page.tsx', +]); + +test('resolves relative import with extension inference', () => { + assert.equal(resolveImport('src/a/c.ts', '../b', fileSet), 'src/b.ts'); +}); + +test('resolves alias import (src/*)', () => { + assert.equal(resolveImport('src/a/c.ts', 'src/d', fileSet), 'src/d.tsx'); +}); + +test('resolves directory import to index file', () => { + assert.equal(resolveImport('src/a/c.ts', 'src/lib', fileSet), 'src/lib/index.ts'); +}); + +test('returns null for bare/external specifiers', () => { + assert.equal(resolveImport('src/a/c.ts', 'react', fileSet), null); + assert.equal(resolveImport('src/a/c.ts', '@mui/material', fileSet), null); +}); + +test('returns null for unresolvable relative import', () => { + assert.equal(resolveImport('src/a/c.ts', './nope', fileSet), null); +}); diff --git a/.claude/review/engine/resolveRules.cjs b/.claude/review/engine/resolveRules.cjs new file mode 100644 index 0000000000..9dffd43ec6 --- /dev/null +++ b/.claude/review/engine/resolveRules.cjs @@ -0,0 +1,25 @@ +'use strict'; +const { minimatch } = require('minimatch'); + +const OPTS = { dot: true }; + +function resolveRules(agentId, files, config) { + const agent = (config.agents || []).find((a) => a.id === agentId); + const rules = []; + const seen = new Set(); + const add = (r) => { + if (!seen.has(r)) { + seen.add(r); + rules.push(r); + } + }; + for (const r of (agent && agent.rules) || []) add(r); + for (const pr of config.path_rules || []) { + if (files.some((f) => pr.paths.some((g) => minimatch(f, g, OPTS)))) { + for (const r of pr.rules) add(r); + } + } + return rules; +} + +module.exports = { resolveRules }; diff --git a/.claude/review/engine/resolveRules.test.cjs b/.claude/review/engine/resolveRules.test.cjs new file mode 100644 index 0000000000..7979079ebc --- /dev/null +++ b/.claude/review/engine/resolveRules.test.cjs @@ -0,0 +1,26 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveRules } = require('./resolveRules.cjs'); + +const config = { + agents: [ + { id: 'security', rules: ['rules/security.md'] }, + { id: 'ux', rules: ['rules/ux.md'] }, + ], + path_rules: [ + { paths: ['pages/api/**'], rules: ['rules/security.md'] }, + { paths: ['src/components/**/*.tsx'], rules: ['rules/ux.md'] }, + { paths: ['src/components/Reports/**'], rules: ['rules/financial.md'] }, + ], +}; + +test('agent rules + matching path_rules, deduped', () => { + const rules = resolveRules('ux', ['src/components/Reports/R.tsx'], config); + assert.deepEqual(rules, ['rules/ux.md', 'rules/financial.md']); +}); + +test('no path_rules match -> only agent rules', () => { + const rules = resolveRules('security', ['src/lib/x.ts'], config); + assert.deepEqual(rules, ['rules/security.md']); +}); diff --git a/.claude/review/engine/rulesCoverage.test.cjs b/.claude/review/engine/rulesCoverage.test.cjs new file mode 100644 index 0000000000..5a5c365c16 --- /dev/null +++ b/.claude/review/engine/rulesCoverage.test.cjs @@ -0,0 +1,27 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { existsSync, statSync } = require('node:fs'); +const path = require('node:path'); +const { loadConfig } = require('./loadConfig.cjs'); + +const root = path.join(__dirname, '..'); +const cfg = loadConfig({ + configPath: path.join(root, 'config.yml'), + schemaPath: path.join(root, 'config.schema.json'), +}); + +function referencedRules() { + const set = new Set(); + for (const a of cfg.agents) for (const r of a.rules || []) set.add(r); + for (const pr of cfg.path_rules || []) for (const r of pr.rules) set.add(r); + return [...set]; +} + +test('every rule doc referenced by config exists and is non-empty', () => { + for (const rel of referencedRules()) { + const p = path.join(root, rel); + assert.ok(existsSync(p), `missing rule doc: ${rel}`); + assert.ok(statSync(p).size > 200, `rule doc too small (placeholder?): ${rel}`); + } +}); diff --git a/.claude/review/engine/run-tests.cjs b/.claude/review/engine/run-tests.cjs new file mode 100644 index 0000000000..5ddd6d1d88 --- /dev/null +++ b/.claude/review/engine/run-tests.cjs @@ -0,0 +1,9 @@ +'use strict'; +// Requires every *.test.cjs here so node:test runs them in ONE process. +// (yarn node injects the PnP loader; node --test workers would not — do not use --test.) +const { readdirSync } = require('node:fs'); +const { join } = require('node:path'); + +for (const f of readdirSync(__dirname).sort()) { + if (f.endsWith('.test.cjs')) require(join(__dirname, f)); +} diff --git a/.claude/review/engine/schema.test.cjs b/.claude/review/engine/schema.test.cjs new file mode 100644 index 0000000000..0a71b643d2 --- /dev/null +++ b/.claude/review/engine/schema.test.cjs @@ -0,0 +1,12 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +// Schema uses the JSON Schema draft 2020-12 dialect; Ajv's 2020 entry point +// ships that meta-schema (the default `ajv` export only knows draft-07). +const Ajv = require('ajv/dist/2020').default || require('ajv/dist/2020'); +const schema = require('../config.schema.json'); + +test('config.schema.json is a valid, compilable JSON Schema', () => { + const validate = new Ajv({ allErrors: true }).compile(schema); // throws if malformed + assert.equal(typeof validate, 'function'); +}); diff --git a/.claude/review/engine/scoreRisk.cjs b/.claude/review/engine/scoreRisk.cjs new file mode 100644 index 0000000000..0ce82dba34 --- /dev/null +++ b/.claude/review/engine/scoreRisk.cjs @@ -0,0 +1,51 @@ +'use strict'; +const { minimatch } = require('minimatch'); + +const OPTS = { dot: true }; + +function isExcluded(file, config) { + return (config.excluded_paths || []).some((g) => minimatch(file, g, OPTS)); +} + +function patternPoints(file, config) { + let max = 0; + for (const p of config.risk.patterns) { + if (minimatch(file, p.glob, OPTS)) max = Math.max(max, p.points); + } + return max; +} + +function volumePoints(linesChanged, config) { + for (const v of config.risk.volume_multiplier) { + if (v.upTo === null || linesChanged <= v.upTo) return v.points; + } + return 0; +} + +function levelFor(score, config) { + for (const l of config.risk.levels) { + const [min, max] = l.range; + if (score >= min && (max === null || score <= max)) return l; + } + return config.risk.levels[config.risk.levels.length - 1]; +} + +function scoreRisk({ files, linesChanged, scope = 'single_feature', special = [] }, config) { + const reviewed = files.filter((f) => !isExcluded(f, config)); + const patternScore = reviewed.reduce((s, f) => s + patternPoints(f, config), 0); + const volumeScore = volumePoints(linesChanged, config); + const specialMap = new Map(config.risk.special.map((s) => [s.when, s.points])); + const specialScore = special.reduce((s, k) => s + (specialMap.get(k) || 0), 0); + const subtotal = patternScore + volumeScore + specialScore; + const scopeMultiplier = config.risk.scope_multiplier[scope] ?? 1.0; + const score = Math.round(subtotal * scopeMultiplier); + const lvl = levelFor(score, config); + return { + score, + level: lvl.level, + reviewer: lvl.reviewer, + factors: { patternScore, volumeScore, specialScore, scopeMultiplier, subtotal }, + }; +} + +module.exports = { scoreRisk, isExcluded, patternPoints, volumePoints, levelFor }; diff --git a/.claude/review/engine/scoreRisk.test.cjs b/.claude/review/engine/scoreRisk.test.cjs new file mode 100644 index 0000000000..4324226173 --- /dev/null +++ b/.claude/review/engine/scoreRisk.test.cjs @@ -0,0 +1,63 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { scoreRisk } = require('./scoreRisk.cjs'); + +const config = { + risk: { + patterns: [ + { glob: 'src/lib/apollo/{client,link,cache,ssrClient}.ts', points: 3, tier: 'critical' }, + { glob: 'pages/api/Schema/**/*.{ts,graphql}', points: 2, tier: 'high' }, + { glob: 'src/components/**/*.{ts,tsx}', points: 1, tier: 'medium' }, + { glob: '**/*.test.{ts,tsx}', points: 0, tier: 'low' }, + ], + volume_multiplier: [ + { upTo: 50, points: 0 }, + { upTo: 200, points: 1 }, + { upTo: 500, points: 2 }, + { upTo: 1000, points: 3 }, + { upTo: null, points: 4 }, + ], + scope_multiplier: { single_feature: 1.0, cross_cutting: 1.7 }, + special: [{ when: 'critical_pkg_update', points: 3 }], + levels: [ + { range: [0, 3], level: 'LOW', reviewer: 'entry' }, + { range: [4, 6], level: 'MEDIUM', reviewer: 'entry' }, + { range: [7, 9], level: 'HIGH', reviewer: 'experienced' }, + { range: [10, null], level: 'CRITICAL', reviewer: 'Caleb Cox (senior)' }, + ], + }, + excluded_paths: ['**/*.snap'], +}; + +test('UI-only small change scores LOW', () => { + const r = scoreRisk({ files: ['src/components/Tasks/TaskRow.tsx'], linesChanged: 30 }, config); + assert.equal(r.factors.patternScore, 1); + assert.equal(r.factors.volumeScore, 0); + assert.equal(r.score, 1); + assert.equal(r.level, 'LOW'); +}); + +test('cross-cutting Apollo + Schema + pkg update scores CRITICAL', () => { + const r = scoreRisk( + { + files: ['src/lib/apollo/cache.ts', 'src/lib/apollo/link.ts', 'pages/api/Schema/Foo/resolvers.ts'], + linesChanged: 600, + scope: 'cross_cutting', + special: ['critical_pkg_update'], + }, + config, + ); + assert.equal(r.factors.patternScore, 8); // 3 + 3 + 2 + assert.equal(r.factors.volumeScore, 3); // 600 -> upTo 1000 + assert.equal(r.factors.specialScore, 3); + assert.equal(r.factors.subtotal, 14); + assert.equal(r.score, 24); // round(14 * 1.7) + assert.equal(r.level, 'CRITICAL'); +}); + +test('excluded files do not contribute to score', () => { + const r = scoreRisk({ files: ['__snapshots__/x.snap'], linesChanged: 10 }, config); + assert.equal(r.factors.patternScore, 0); + assert.equal(r.score, 0); +}); diff --git a/.claude/review/engine/selectAgents.cjs b/.claude/review/engine/selectAgents.cjs new file mode 100644 index 0000000000..743267679d --- /dev/null +++ b/.claude/review/engine/selectAgents.cjs @@ -0,0 +1,60 @@ +'use strict'; +const { minimatch } = require('minimatch'); + +const OPTS = { dot: true }; + +function isExcluded(file, config) { + return (config.excluded_paths || []).some((g) => minimatch(file, g, OPTS)); +} + +// The reviewer's own definition files legitimately contain trigger vocabulary as DATA +// (config.yml lists the trigger keywords; rule docs describe them). Scanning them for content +// triggers self-matches. Drop them — plus markdown/docs and excluded paths — from content scanning. +const DEFN_RE = /(^|\/)\.claude\/review\/config(\.schema)?\.(ya?ml|json)$/; + +// Keep only diff hunks for reviewable CODE files so content triggers match real code, not prose +// or the reviewer's own config/rule definitions. +function codeDiff(diffText, config) { + if (!diffText) return ''; + const blocks = diffText.split(/(?=^diff --git )/m); + const kept = []; + for (const b of blocks) { + const m = b.match(/^diff --git a\/\S+ b\/(\S+)/m); + if (!m) { + kept.push(b); // preamble or a non-`diff --git` snippet — keep (back-compat for raw snippets) + continue; + } + const file = m[1]; + if (file.endsWith('.md') || DEFN_RE.test(file) || isExcluded(file, config)) continue; + kept.push(b); + } + return kept.join(''); +} + +function agentMatches(agent, files, contentText) { + if (agent.always) return 'always'; + const t = agent.triggers || {}; + for (const f of files) { + for (const g of t.paths || []) { + if (minimatch(f, g, OPTS)) return `path:${g}`; + } + } + for (const c of t.content || []) { + if (contentText.includes(c)) return `content:${c}`; + } + return null; +} + +function selectAgents({ files, diffText }, config) { + const reviewed = files.filter((f) => !isExcluded(f, config)); + const contentText = codeDiff(diffText, config); + const out = []; + for (const a of config.agents) { + if (a.enabled === false) continue; + const matchedBy = agentMatches(a, reviewed, contentText); + if (matchedBy) out.push({ id: a.id, model: a.model || 'smart', matchedBy }); + } + return out; +} + +module.exports = { selectAgents, agentMatches, codeDiff }; diff --git a/.claude/review/engine/selectAgents.test.cjs b/.claude/review/engine/selectAgents.test.cjs new file mode 100644 index 0000000000..1bfcb4fccc --- /dev/null +++ b/.claude/review/engine/selectAgents.test.cjs @@ -0,0 +1,32 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { selectAgents } = require('./selectAgents.cjs'); + +const config = { + excluded_paths: ['**/*.snap'], + agents: [ + { id: 'architecture', always: true }, + { id: 'testing', always: true }, + { id: 'standards', always: true }, + { id: 'security', triggers: { paths: ['pages/api/**'], content: ['process.env.'] } }, + { id: 'ux', model: 'opus', triggers: { paths: ['src/components/**/*.tsx'] } }, + { id: 'financial', enabled: false, triggers: { paths: ['src/components/Reports/**'] } }, + ], +}; + +test('UI-only change selects always-on agents + ux', () => { + const sel = selectAgents({ files: ['src/components/Tasks/TaskRow.tsx'], diffText: '+ const x = 1;' }, config); + assert.deepEqual(sel.map((a) => a.id).sort(), ['architecture', 'standards', 'testing', 'ux']); + assert.equal(sel.find((a) => a.id === 'ux').model, 'opus'); +}); + +test('content trigger selects security via process.env', () => { + const sel = selectAgents({ files: ['src/lib/foo.ts'], diffText: '+ const k = process.env.SECRET;' }, config); + assert.ok(sel.some((a) => a.id === 'security' && a.matchedBy === 'content:process.env.')); +}); + +test('disabled agent never selected', () => { + const sel = selectAgents({ files: ['src/components/Reports/Report.tsx'], diffText: '' }, config); + assert.ok(!sel.some((a) => a.id === 'financial')); +}); diff --git a/.claude/review/learnings/feedback.jsonl b/.claude/review/learnings/feedback.jsonl new file mode 100644 index 0000000000..e69de29bb2 diff --git a/.claude/review/learnings/learnings.yml b/.claude/review/learnings/learnings.yml new file mode 100644 index 0000000000..b61e068445 --- /dev/null +++ b/.claude/review/learnings/learnings.yml @@ -0,0 +1,2 @@ +version: 1 +learnings: [] diff --git a/.claude/review/rules/architecture.md b/.claude/review/rules/architecture.md new file mode 100644 index 0000000000..45ddf27bca --- /dev/null +++ b/.claude/review/rules/architecture.md @@ -0,0 +1,14 @@ +# Architecture — Focus Areas + +- **Dual GraphQL boundary** — new queries should route cleanly to one server. Mixing rootFields (primary API) with REST-proxy-only fields in the same operation is a smell; the Apollo link will split them but the result can be confusing and harder to cache +- **REST-proxy layering** — new proxy queries follow the pattern: `pages/api/Schema//.graphql` → `resolvers.ts` in the same folder → `datahandler.ts` sibling file → REST call in `graphql-rest.page.ts`. Deviations should be justified +- **Thin page components** — route-level pages (`pages/**/*.page.tsx`) should compose feature components, not contain business logic. Logic lives in hooks or feature components +- **Component feature organization** — new components belong under `src/components//`; components used across multiple features go in `src/components/Shared/`. Don't add one-off components to `Shared/` +- **Hook placement** — reusable hooks in `src/hooks/`; feature-specific hooks stay next to their components +- **Pages Router conventions** — `.page.tsx` suffix for routes, `.page.ts` for API routes, no App Router patterns (`app/`, `layout.tsx`, `use client`, RSC) +- **useEffect dependency arrays** — verify all referenced values are listed; flag empty arrays that reference props/state (stale closures); flag effects that should be `useMemo` or event handlers instead +- **Error boundaries** — new top-level views should be wrapped in an error boundary or compose one that is. Apollo errors should surface to the user, not be swallowed +- **N+1 / waterfall queries** — component mounts that fire Apollo queries in a `useEffect` chain (query A → then query B based on A's result) should be collapsed into one operation or use `skip` + parallel queries +- **Prop drilling vs context vs Apollo cache** — if a value is passed through 3+ layers, consider Apollo cache, a context, or moving the data fetch closer to the consumer +- **Technical debt** — debt added vs reduced by this PR. Refactors that only move code without improving clarity are neutral, not positive +- **Pattern compliance** with `CLAUDE.md` and the existing codebase — new code should look like the code around it unless the existing pattern is what's being fixed diff --git a/.claude/review/rules/data-integrity.md b/.claude/review/rules/data-integrity.md new file mode 100644 index 0000000000..a78e248d2b --- /dev/null +++ b/.claude/review/rules/data-integrity.md @@ -0,0 +1,17 @@ +# Data Integrity — Focus Areas + +This is where domain-specific data invariants belong. + +- **Apollo cache normalization** — missing `id` fields cause stale or duplicate cache entries. Every selection set for a normalizable type must include `id` +- **Cache type policies** (`src/lib/apollo/cache.ts`) — any change to `typePolicies`, `keyFields`, or `merge` functions can silently corrupt cached data across the app. Treat as high-severity +- **Pagination merge functions** — `fetchMore` merge must dedupe, preserve order, and handle cursor edge cases (empty page, duplicate cursor, cursor missing) +- **Optimistic responses** — must match server response shape exactly, including `__typename` and `id`; mismatches cause cache misses and UI flicker +- **Mutation cache updates** — every mutation that changes displayed data must include `update`, `refetchQueries`, `cache.modify`, or `cache.evict` — otherwise the UI shows stale data +- **REST-proxy data handlers** — field mapping from snake_case (REST) to camelCase (GraphQL) is a frequent place for silent field-dropping. Verify every field in the REST response is either mapped or intentionally ignored +- **Null vs undefined in mutation variables** — GraphQL distinguishes between "field not set" (`undefined`, omitted) and "field explicitly null" (`null`). The REST proxy may reject one or the other. Form state → mutation variable mapping must be intentional about this +- **Form submit → mutation mapping** — Formik values passed directly to a mutation can include unexpected fields if the Yup schema is looser than the GraphQL input type. Flag any `...values` spread into mutation variables without an explicit field allowlist +- **Date serialization** — dates sent to the server should be in a consistent format (ISO-8601 / Luxon); flag any manual `.toString()` or `new Date()` in mutation variables +- **Currency precision** — monetary values must not be truncated or rounded during form handling; the display boundary is the only place for rounding +- **Pagination over `nodes`** — aggregating client-side over a single page of `nodes` silently ignores unpaginated data. Prefer server-provided totals +- **Filter/search state** — when filters change, paginated data must be re-fetched from the first page, not appended to existing pages +- **Optimistic updates on lists** — adding an item optimistically must place it in the correct sort position, not just at the end diff --git a/.claude/review/rules/financial.md b/.claude/review/rules/financial.md new file mode 100644 index 0000000000..980e1d4ed1 --- /dev/null +++ b/.claude/review/rules/financial.md @@ -0,0 +1,39 @@ +# Financial Reporting — Focus Areas + +MPDX displays and calculates donation/partner-giving aggregations across dozens of financial reports. Display-side miscalculations silently mislead staff about their support status. This agent supplements the generic Data Integrity agent with domain-specific invariants. + +**Trigger conditions:** + +- Any file under `src/components/Reports/**` +- Any file under `src/components/HrTools/**` +- Any file under `src/components/Reports/GoalCalculator/**`, `src/components/HrTools/GoalCalculator/**`, or `src/components/HrTools/PdsGoalCalculator/**` +- Any file under `src/components/Reports/SalaryCalculator/**` or `src/components/HrTools/SalaryCalculator/**` +- Any file under `src/components/EditDonationModal/**` +- Any file under `src/components/Reports/AdditionalSalaryRequest/**`, `src/components/HrTools/AdditionalSalaryRequest/**`, or `src/components/HrTools/MinisterHousingAllowance/**` +- Any file under `src/components/Dashboard/MonthlyGoal/**`, `src/components/Dashboard/DonationHistories/**` +- Diff content contains any of: `amount`, `currency`, `convertedAmount`, `pledgeAmount`, `goal`, `balance`, `total`, `sum(`, `reduce((`, `.toFixed(`, `Math.round(`, `Number(`, `parseFloat(`, `parseInt(` inside `src/components/Reports/**`, `src/components/HrTools/**`, or other goal/donation components + +**Focus areas:** + +- **Money is never a JavaScript `number` for arithmetic.** Check for floating-point arithmetic on money values — any `amount + amount`, `amount * rate`, or `.reduce` accumulating amounts must round at the display boundary. +- **Currency mixing.** Donations arrive in multiple currencies; verify no code path sums `amount` (native currency) across rows with different `currencyCode`. Aggregations must use `convertedAmount` (or equivalent) in a single reporting currency. +- **Rounding consistency.** Rounding should happen at the display boundary via `intlFormat` / `Intl.NumberFormat`, not sprinkled through calculation code. Flag any `.toFixed(n)` used inside aggregation logic. +- **Missing/null amounts.** Donations, pledges, and goals may be `null` or `undefined`. Verify nullish handling (`?? 0`) is present where aggregations happen, and that `null` is not silently coerced to `0` where it should surface as "unknown." +- **Date-window correctness.** Fourteen-month and expected-monthly reports depend on correct month boundaries, timezone handling (use Luxon — not `new Date()`), and inclusive/exclusive range semantics. Flag any `new Date()` in report logic. +- **Goal-calculation consistency.** Goal math in `GoalCalculator` / `PdsGoalCalculator` must match across UI layers — flag any duplicate calculation logic that could drift. +- **Empty-state / zero-state correctness.** A report with zero donations should render "no data" — not `$0.00` that looks like real data. +- **GraphQL aggregation fields vs client-side summing.** Prefer server-provided totals (`totalAmount`, `sum`, etc.) over client-side `.reduce` when both are available — client sums over a paginated `nodes` list are a silent bug. + +**Output format:** Use the standard agent output format with `Critical Financial Issues`, `Financial Concerns`, `Financial Suggestions`, plus a `Financial Checklist`: + +``` +### Financial Checklist +- Arithmetic on money values safe: Yes/No/N/A +- Currency mixing prevented: Yes/No/N/A +- Rounding at display boundary only: Yes/No/N/A +- Null/undefined amounts handled: Yes/No/N/A +- Luxon used for dates (not `new Date()`): Yes/No/N/A +- Server-provided aggregations preferred: Yes/No/N/A +``` + +**Note:** If your analysis determines that the changes do not actually affect financial logic (e.g., the keyword match was a false positive — `amount` could be a form field label), state "No financial calculation code in this PR" clearly and skip the detailed review. diff --git a/.claude/review/rules/security.md b/.claude/review/rules/security.md new file mode 100644 index 0000000000..3e03404d4b --- /dev/null +++ b/.claude/review/rules/security.md @@ -0,0 +1,19 @@ +# Security — Focus Areas + +Project-specific concerns added to the Security agent's universal checks. + +- **NextAuth callback handling** (`pages/api/auth/[...nextauth].page.ts`) — verify token persistence, refresh logic, session expiry, callback URL validation against an allowlist (open-redirect risk) +- **API OAuth sign-in flow** (`pages/api/auth/apiOauthSignIn.ts`) — PKCE handling, state parameter validation, redirect URI verification +- **REST-proxy token forwarding** (`pages/api/graphql-rest.page.ts`) — verify bearer tokens are forwarded correctly, never logged, and never exposed in error responses +- **Apollo link auth headers** (`src/lib/apollo/link.ts`) — tokens attached consistently, never logged, correct handling when a token is missing +- **Environment variable exposure** — server-only variables must NOT be prefixed with `NEXT_PUBLIC_` (that ships them to the client bundle); audit every new `process.env.*` reference +- **Client-side validation parity** — every Yup rule, every `disabled` button, every `required` field must have a server-side equivalent. If a mutation silently trusts the client, flag it +- **Impersonation flow** — changes touching impersonation (`pages/api/auth/impersonate/**`, `src/components/User/impersonate*`) must verify the authorizer's role and log the action +- **File uploads** — `pages/api/uploads/**`, `pages/api/Schema/uploads/**` — verify content-type validation, size limits, filename sanitization (no path traversal), and that upload tokens are scoped +- **CSP and security headers** in `next.config.{js,ts}` — any weakening (new `unsafe-inline`, `unsafe-eval`, added origins) is a red flag +- **XSS surfaces** — `dangerouslySetInnerHTML`, `innerHTML`, direct DOM writes; flag any use in new code and verify input is sanitized +- **Open redirect** — any `router.push(value)` or `window.location = value` where `value` comes from a query parameter must be validated against an allowlist +- **GraphQL variable injection** — never build GraphQL operations via string concatenation; only use query variables +- **Admin/coaching authorization** — settings, org admin, and coaching views must check the user's role on each mutation, not just on the initial page load +- **CI/CD workflow security** — any change to `.github/workflows/**` must verify permission scopes are minimal, secrets are not exposed in logs, and trigger conditions cannot be abused to bypass review +- **Review process integrity** — changes to `.claude/commands/**`, `.claude/rules/**`, or `.claude/settings.json` must verify risk scoring is not weakened, severity thresholds are not lowered, critical checks are not stripped, and newly enabled plugins/marketplaces come from trusted org-controlled sources diff --git a/.claude/review/rules/standards.md b/.claude/review/rules/standards.md new file mode 100644 index 0000000000..bb86846171 --- /dev/null +++ b/.claude/review/rules/standards.md @@ -0,0 +1,55 @@ +# Standards — Checklist + +Every item here is mandatory. The Standards agent must report compliance per item. + +**Exports & Naming** + +- [ ] **Named exports only** — no `export default` in components, hooks, or libs (`export const ComponentName: React.FC = () => {}`) +- [ ] **File naming** — components PascalCase (`Foo.tsx`), pages kebab-case with `.page.tsx`, API routes with `.page.ts`, tests colocated as `Foo.test.tsx`, GraphQL as PascalCase `.graphql` +- [ ] **GraphQL operation names** — descriptive, not prefixed with `Get` or `Load` (e.g. `ContactDetails`, not `GetContactDetails`) +- [ ] **Hook names** — must start with `use` and live in `src/hooks/` (reusable) or next to the component (feature-specific) + +**Localization (i18n)** + +- [ ] Every user-visible string uses `useTranslation` / `t()` — no hard-coded display text in JSX, `Alert`, `Snackbar`, error messages, `aria-label`, or form labels +- [ ] No string interpolation inside `t()` calls — use interpolation variables: `t('Hello {{name}}', { name })`, not `t(`Hello ${name}`)` +- [ ] No dynamic `t()` keys (`t(varName)`) — extraction tool can't find them + +**GraphQL & Apollo** + +- [ ] Every query/mutation selection set includes `id` on normalizable types (for cache normalization) +- [ ] Any `.graphql` change has been verified by running `yarn gql` successfully +- [ ] Any query returning `nodes` either handles pagination via `pageInfo` / `after` / `fetchMore`, or documents why the default 25-item limit is sufficient +- [ ] Mutations that change cached data include either `update`, `refetchQueries`, or `cache.evict` to reflect the change in the UI +- [ ] Apollo routing awareness — when adding a field, check `src/graphql/rootFields.generated.ts` to know which server handles it; don't mix rootFields with REST-proxy-only fields in one operation +- [ ] No raw `fetch` or `axios` calls for data that could go through Apollo — centralize in GraphQL. Exception: the REST-proxy boundary layer (`pages/api/graphql-rest.page.ts` and `pages/api/Schema/**/datahandler*.ts`) uses `fetch` intentionally to call the upstream REST API + +**TypeScript** + +- [ ] No `any` types in new code (use `unknown` + narrowing, or proper generics) +- [ ] No `@ts-ignore` / `@ts-expect-error` without an inline comment explaining why +- [ ] No non-null assertions (`!`) on values that could legitimately be null — prefer explicit null checks +- [ ] Generated operation types from `.generated.ts` files are used for Apollo hooks and mocks + +**Forms** + +- [ ] Forms use Formik + Yup — no manual `useState` form state for anything beyond trivial single-field inputs +- [ ] Every Yup schema has matching server-side validation (don't rely on client-only validation) +- [ ] Submit buttons are `disabled` while `isSubmitting` is true + +**Testing** + +- [ ] Every new component, hook, and lib function has a colocated `*.test.{ts,tsx}` +- [ ] Component tests using GraphQL wrap in `GqlMockedProvider<{ OperationName: OperationNameQuery }>` with typed mocks +- [ ] Tests use `findBy*` for async assertions rather than `waitFor(() => getBy*)` +- [ ] No `any` in test types — use generated operation types for mock shapes +- [ ] No global `fetch` mocking — use Apollo mocks at the operation level + +**Code Quality** + +- [ ] Passes `yarn lint` and `yarn lint:ts` +- [ ] No debug output (`console.log`, `console.debug`, `debugger`, `// TODO` without a Jira/MPDX ticket reference) +- [ ] No `new Date()` — use Luxon (`DateTime.now()`, `DateTime.local()`) per project convention +- [ ] No unused imports or variables +- [ ] No commented-out code blocks (delete, don't comment) +- [ ] No empty `catch {}` blocks that swallow errors silently diff --git a/.claude/review/rules/testing.md b/.claude/review/rules/testing.md new file mode 100644 index 0000000000..e6d2804355 --- /dev/null +++ b/.claude/review/rules/testing.md @@ -0,0 +1,15 @@ +# Testing — Focus Areas + +Project-specific testing conventions added to the Testing agent's universal checks. + +- **`GqlMockedProvider` is the only GraphQL mock pattern.** All component tests that hit GraphQL must wrap in ` mocks={...}>` with typed generics so mock shapes are type-checked at compile time +- **`mutationSpy` + `toHaveGraphqlOperation(...)` pattern** is preferred over brittle snapshot-based assertions for verifying mutations +- **`toHaveTableStructure(...)` for full table contents** — when asserting more than a couple table cells, use `expect(getByRole('table')).toHaveTableStructure({ columnHeaders, rowHeaders, cells })` instead of ad-hoc `getByRole('cell')` +- **`findBy*` for async assertions** — prefer `await findByText(...)` over `await waitFor(() => getByText(...))` +- **No `fetch` mocking** — never mock `global.fetch` or `window.fetch`; use Apollo operation-level mocks +- **No `any` in test types** — mock shapes use generated operation types (`ContactDetailsQuery`, etc.) from `.generated.ts` files +- **`i18next` in tests** — `t()` returns the translation key by default in test env; don't assert on translated strings unless the test specifically exercises i18n +- **Test file colocation** — test files live next to the component under test (`Foo.test.tsx` alongside `Foo.tsx`), not in a separate `__tests__/` tree (except for shared test utilities) +- **Time-dependent tests** — use Jest fake timers (`jest.useFakeTimers()`) or Luxon's `Settings.now` override; never rely on actual system time +- **Edge case coverage** — every new component test should include: empty state, loading state, error state, at least one happy path, and boundary conditions (0 items, 1 item, many items) +- **Error path testing** — not just happy paths. Test validation failures, Apollo error responses, and user-visible error states diff --git a/.claude/review/rules/ux.md b/.claude/review/rules/ux.md new file mode 100644 index 0000000000..54c58a450d --- /dev/null +++ b/.claude/review/rules/ux.md @@ -0,0 +1,21 @@ +# UX — Focus Areas + +Project-specific UX/UI conventions layered on top of the UX agent's universal checks. + +- **Material UI v5 conventions** — use the `sx` prop for styling; avoid `makeStyles` (legacy v4) and avoid inline `style={...}` (breaks theme-aware styling and responsive breakpoints). Styled components (`styled(...)`) are acceptable for reused patterns +- **Theme tokens, not hardcoded values** — use `theme.palette.*`, `theme.spacing(n)`, `theme.breakpoints.*`. Flag raw hex colors, pixel values, and magic numbers +- **Responsive design** — MUI breakpoints (`xs`, `sm`, `md`, `lg`, `xl`) via `sx={{ [theme.breakpoints.down('md')]: {...} }}` or the shorthand `sx={{ display: { xs: 'block', md: 'flex' } }}`. New components must render correctly at mobile breakpoints +- **Loading states** — every Apollo query must render a loading state (MUI `Skeleton` or `CircularProgress`), not render nothing or flash stale content +- **Error states** — every Apollo query must render an error state (`` or similar). Never let an error silently render empty content +- **Formik field wiring** — use ``, `useField`, or `getFieldProps` consistently; form fields must wire `name`, `value`, `onChange`, `onBlur`, `error`, and `helperText` to the Formik state +- **Form error visibility** — validation errors must be visible next to the field (MUI `helperText` with `error` prop), not only in a toast or summary +- **Accessibility (a11y)** + - All interactive elements need accessible names (`aria-label`, `aria-labelledby`, or visible text) + - Icon-only buttons must have `aria-label` (MUI `IconButton` doesn't add one automatically) + - Form fields must have associated labels (MUI `TextField` with `label` prop, or explicit `` + `htmlFor`) + - Dialogs use `` with `aria-labelledby` pointing at the title + - Color should never be the only indicator of state (add icons or text) + - Keyboard navigation works (tab order, Enter/Space activation, Escape closes modals) +- **Translation coverage** — new user-visible strings must have i18n keys added; verify `yarn extract` would pick them up (no dynamic `t()` keys, no string interpolation inside `t()`) +- **Snackbar / notification usage** — success/error feedback goes through the project's notification system, not ad-hoc `alert()` or inline text +- **Dialog UX** — dialogs have clear primary/secondary actions, disable the primary action while submitting, and close on success diff --git a/.claude/rules/code-review.md b/.claude/rules/code-review.md index 4a1a4e1215..2c63b169e9 100644 --- a/.claude/rules/code-review.md +++ b/.claude/rules/code-review.md @@ -1,324 +1,9 @@ -# MPDX React — Code Review Rules +# MPDX React — Code Review Rules (moved) -Project-specific rules layered on top of `CLAUDE.md` for `/quality:agent-review`. +These rules now live in the declarative review core: -**Stack:** Next.js 15 (Pages Router) · React 18 · TypeScript · Material UI v5 · Apollo Client (dual GraphQL) · Formik + Yup · Jest + React Testing Library · react-i18next · NextAuth (Okta / API OAuth). +- Config (risk scoring, agents, triggers, exclusions): `.claude/review/config.yml` +- Prose rule docs (per-agent focus areas, standards): `.claude/review/rules/` +- Engine + tests: `.claude/review/engine/` (run `yarn test:review`) -**Key architectural facts the agents should know:** - -- Two GraphQL servers are routed by Apollo Link: the primary API (`https://api.mpdx.org/graphql`) and a Next.js REST-proxy lambda under `pages/api/graphql-rest.page.ts`. Routing is driven by `src/graphql/rootFields.generated.ts`. -- REST-proxy schemas live in `pages/api/Schema//` and follow the pattern: `.graphql` → `resolvers.ts` → `datahandler.ts` (sibling file) → REST call. -- Every user-visible string is localized via `useTranslation` / `t()` and extracted to `public/locales/`. -- Named exports only — no `export default` in components, hooks, or libs. -- Apollo cache normalization depends on every selection set including `id` on normalizable types. - ---- - -## Critical File Patterns - -Files that control auth, routing, Apollo setup, build config, or the CI/review pipeline. Each contributes +3 to risk score (on top of the universal defaults). - -- `pages/api/auth/[...nextauth].page.ts` — NextAuth handler (OAuth callbacks, session) -- `pages/api/auth/apiOauthSignIn.ts` — API OAuth sign-in flow -- `pages/api/auth/helpers.ts` — shared auth helpers (token handling) -- `pages/api/graphql-rest.page.ts` — REST-proxy entry point (token forwarding lives here) -- `pages/api/Schema/index.ts` — REST-proxy schema registration -- `pages/_app.page.tsx` — app-level providers and global wiring -- `next.config.{js,ts}` — build-time config, headers, rewrites, CSP -- `codegen.ts`, `codegen.*.ts` — GraphQL codegen configuration -- `package.json` — dependency changes (new packages trigger `## Special Pattern Detection` below) -- `.github/workflows/**` — CI/CD workflows -- `src/lib/apollo/client.ts` — Apollo Client instantiation (auth headers, default options, link chain composition) -- `src/lib/apollo/link.ts` — Apollo Link chain (dual-server routing, auth token attachment, error handling) -- `src/lib/apollo/cache.ts` — Apollo InMemoryCache config (type policies, merge functions, cache normalization) -- `src/lib/apollo/ssrClient.ts` — SSR Apollo Client (server-side data fetching, hydration) -- `.claude/commands/**`, `.claude/rules/**`, `.claude/settings.json` — review-process and harness definitions (protect against weakening AI review or enabling unvetted plugins/marketplaces) - -## High-Risk File Patterns - -Each contributes +2 to risk score. - -- `pages/api/Schema/**/*.{ts,graphql}` — REST-proxy resolvers, schemas, and data handlers (silent data-reshaping risk) -- `pages/api/Schema/**/datahandler*.ts` — response transformation feeding the REST proxy -- `pages/api/**/*.page.ts` (excluding `auth/` and `graphql-rest` — already Critical) — other API/lambda routes -- `src/components/**/*.graphql` — GraphQL operations (cache normalization and pagination correctness) -- `src/lib/apollo/**/*.ts` — any Apollo helper beyond the Critical files above -- `src/components/User/**`, anything handling impersonation or account-list switching -- `pages/api/Schema/uploads/**`, `pages/api/uploads/**` — file upload handlers -- `src/components/Shared/**` — shared components (blast radius) - -## Medium-Risk File Patterns - -Each contributes +1 to risk score. - -- `src/components/**/*.{ts,tsx}` — feature components (excluding Shared, already High-Risk) -- `src/hooks/**/*.ts` — custom hooks -- `src/lib/**/*.ts` (excluding `src/lib/apollo/**`) — utilities and helpers -- `pages/**/*.page.tsx` — route-level page components (excluding auth/api) -- `src/components/Settings/**` — settings UI (touches user preferences, org config) -- `src/theme.ts`, `src/theme/**` — MUI theme and design tokens - -## Low-Risk File Patterns - -Zero points. These override or augment the universal Low-Risk defaults. - -- `**/*.test.{ts,tsx}` — test files (content changes only; new test infrastructure should still be reviewed) -- `public/locales/**/*.json` — translation content -- `public/static/**` — static assets -- `public/fonts/**`, `public/images/**` — binary assets -- `**/*.snap` — Jest snapshot files - -## Special Pattern Detection - -Additional risk modifiers, added to the universal defaults. - -- **New package added to `package.json` dependencies/devDependencies:** +2 (supply-chain risk, bundle size impact) -- **Updated critical package** (`next`, `react`, `@apollo/client`, `@mui/material`, `formik`, `next-auth`, `typescript`, `graphql-codegen`): +3 -- **`yarn.lock` changed without matching `package.json` change:** +1 (likely a resolution drift or lockfile hand-edit) -- **`.graphql` file changed without verifying `yarn gql` runs cleanly:** +2 (codegen out of sync — runtime errors likely). Note: `.generated.ts` files are not committed; CI regenerates them. The check is that codegen succeeds, not that generated files appear in the PR -- **New `.graphql` file under `pages/api/Schema/` without matching resolver/dataHandler updates:** +1 -- **New file in `src/hooks/` that uses Apollo hooks without an accompanying test file:** +1 (hooks drive component behavior; untested hooks are landmines) -- **New file in `src/components/` without an accompanying `*.test.tsx`:** +1 -- **Changes to `next.config.{js,ts}` rewrites, headers, CSP, or image domains:** +2 (affects all pages and external requests) -- **Changes to `src/lib/apollo/cache.ts` `typePolicies` or `merge` functions:** +2 (can silently corrupt cached data across the app) - -## Agent Triggers - -Repo-specific triggers that supplement the skill's minimal universal triggers. - -**Security Agent** - -- Any file under `pages/api/**` (all API/lambda routes) -- Any file matching `src/lib/apollo/{link,client,ssrClient}.ts` -- `next.config.{js,ts}`, `pages/_app.page.tsx`, any middleware/CSP/headers config -- `src/lib/extractCookie*.ts`, `src/lib/**auth**`, `src/lib/**session**` -- Changes to `.github/workflows/**` -- Changes adding `process.env.*` references -- Changes to `.claude/commands/**`, `.claude/rules/**`, or `.claude/settings.json` (review process and harness integrity) - -**Data Integrity Agent** - -- `pages/api/Schema/**/*.{ts,graphql}` (REST proxy silently reshapes data) -- `pages/api/Schema/**/datahandler*.ts` -- `src/lib/apollo/cache.ts` (type policies, merge functions, pagination) -- `src/components/**/*.graphql` containing `mutation` keyword -- Any `.graphql` file with `first:`, `after:`, `pageInfo`, or `nodes` (pagination) -- Apollo `update`, `optimisticResponse`, `refetchQueries`, or `cache.modify` / `cache.evict` calls in diff content -- Anywhere the diff content shows manual `__typename` assignment -- `src/graphql/rootFields.generated.ts` (dual-server routing) - -**UX Agent** - -- `src/components/**/*.tsx` -- `pages/**/*.page.tsx` -- `src/theme.ts`, `src/theme/**` -- Changes touching Formik wiring (``, `useFormik`, ``, `ErrorMessage`) -- New translation keys (files under `public/locales/en/**` changed with structural additions, not content translation) - -**Testing Agent** - -- Any file under `src/components/**`, `src/hooks/**`, or `src/lib/**` that is added or modified **without** a corresponding `*.test.{ts,tsx}` change in the same PR -- Any change to test utilities (`__tests__/util/**`) -- New mocks added to `GqlMockedProvider` usage that bypass type checking - -## Domain Agents - -### Financial Reporting Agent - -MPDX displays and calculates donation/partner-giving aggregations across dozens of financial reports. Display-side miscalculations silently mislead staff about their support status. This agent supplements the generic Data Integrity agent with domain-specific invariants. - -**Trigger conditions:** - -- Any file under `src/components/Reports/**` -- Any file under `src/components/HrTools/**` -- Any file under `src/components/Reports/GoalCalculator/**`, `src/components/HrTools/GoalCalculator/**`, or `src/components/HrTools/PdsGoalCalculator/**` -- Any file under `src/components/Reports/SalaryCalculator/**` or `src/components/HrTools/SalaryCalculator/**` -- Any file under `src/components/EditDonationModal/**` -- Any file under `src/components/Reports/AdditionalSalaryRequest/**`, `src/components/HrTools/AdditionalSalaryRequest/**`, or `src/components/HrTools/MinisterHousingAllowance/**` -- Any file under `src/components/Dashboard/MonthlyGoal/**`, `src/components/Dashboard/DonationHistories/**` -- Diff content contains any of: `amount`, `currency`, `convertedAmount`, `pledgeAmount`, `goal`, `balance`, `total`, `sum(`, `reduce((`, `.toFixed(`, `Math.round(`, `Number(`, `parseFloat(`, `parseInt(` inside `src/components/Reports/**`, `src/components/HrTools/**`, or other goal/donation components - -**Focus areas:** - -- **Money is never a JavaScript `number` for arithmetic.** Check for floating-point arithmetic on money values — any `amount + amount`, `amount * rate`, or `.reduce` accumulating amounts must round at the display boundary. -- **Currency mixing.** Donations arrive in multiple currencies; verify no code path sums `amount` (native currency) across rows with different `currencyCode`. Aggregations must use `convertedAmount` (or equivalent) in a single reporting currency. -- **Rounding consistency.** Rounding should happen at the display boundary via `intlFormat` / `Intl.NumberFormat`, not sprinkled through calculation code. Flag any `.toFixed(n)` used inside aggregation logic. -- **Missing/null amounts.** Donations, pledges, and goals may be `null` or `undefined`. Verify nullish handling (`?? 0`) is present where aggregations happen, and that `null` is not silently coerced to `0` where it should surface as "unknown." -- **Date-window correctness.** Fourteen-month and expected-monthly reports depend on correct month boundaries, timezone handling (use Luxon — not `new Date()`), and inclusive/exclusive range semantics. Flag any `new Date()` in report logic. -- **Goal-calculation consistency.** Goal math in `GoalCalculator` / `PdsGoalCalculator` must match across UI layers — flag any duplicate calculation logic that could drift. -- **Empty-state / zero-state correctness.** A report with zero donations should render "no data" — not `$0.00` that looks like real data. -- **GraphQL aggregation fields vs client-side summing.** Prefer server-provided totals (`totalAmount`, `sum`, etc.) over client-side `.reduce` when both are available — client sums over a paginated `nodes` list are a silent bug. - -**Output format:** Use the standard agent output format with `Critical Financial Issues`, `Financial Concerns`, `Financial Suggestions`, plus a `Financial Checklist`: - -``` -### Financial Checklist -- Arithmetic on money values safe: Yes/No/N/A -- Currency mixing prevented: Yes/No/N/A -- Rounding at display boundary only: Yes/No/N/A -- Null/undefined amounts handled: Yes/No/N/A -- Luxon used for dates (not `new Date()`): Yes/No/N/A -- Server-provided aggregations preferred: Yes/No/N/A -``` - -**Note:** If your analysis determines that the changes do not actually affect financial logic (e.g., the keyword match was a false positive — `amount` could be a form field label), state "No financial calculation code in this PR" clearly and skip the detailed review. - -## Standards Checklist - -Every item here is mandatory. The Standards agent must report compliance per item. - -**Exports & Naming** - -- [ ] **Named exports only** — no `export default` in components, hooks, or libs (`export const ComponentName: React.FC = () => {}`) -- [ ] **File naming** — components PascalCase (`Foo.tsx`), pages kebab-case with `.page.tsx`, API routes with `.page.ts`, tests colocated as `Foo.test.tsx`, GraphQL as PascalCase `.graphql` -- [ ] **GraphQL operation names** — descriptive, not prefixed with `Get` or `Load` (e.g. `ContactDetails`, not `GetContactDetails`) -- [ ] **Hook names** — must start with `use` and live in `src/hooks/` (reusable) or next to the component (feature-specific) - -**Localization (i18n)** - -- [ ] Every user-visible string uses `useTranslation` / `t()` — no hard-coded display text in JSX, `Alert`, `Snackbar`, error messages, `aria-label`, or form labels -- [ ] No string interpolation inside `t()` calls — use interpolation variables: `t('Hello {{name}}', { name })`, not `t(`Hello ${name}`)` -- [ ] No dynamic `t()` keys (`t(varName)`) — extraction tool can't find them -- [ ] Every `` component is passed a `t` prop (`...`) - -**GraphQL & Apollo** - -- [ ] Every query/mutation selection set includes `id` on normalizable types (for cache normalization) -- [ ] Any `.graphql` change has been verified by running `yarn gql` successfully -- [ ] Any query returning `nodes` either handles pagination via `pageInfo` / `after` / `fetchMore`, or documents why the default 25-item limit is sufficient -- [ ] Mutations that change cached data include either `update`, `refetchQueries`, or `cache.evict` to reflect the change in the UI -- [ ] Apollo routing awareness — when adding a field, check `src/graphql/rootFields.generated.ts` to know which server handles it; don't mix rootFields with REST-proxy-only fields in one operation -- [ ] No raw `fetch` or `axios` calls for data that could go through Apollo — centralize in GraphQL. Exception: the REST-proxy boundary layer (`pages/api/graphql-rest.page.ts` and `pages/api/Schema/**/datahandler*.ts`) uses `fetch` intentionally to call the upstream REST API - -**TypeScript** - -- [ ] No `any` types in new code (use `unknown` + narrowing, or proper generics) -- [ ] No `@ts-ignore` / `@ts-expect-error` without an inline comment explaining why -- [ ] No non-null assertions (`!`) on values that could legitimately be null — prefer explicit null checks -- [ ] Generated operation types from `.generated.ts` files are used for Apollo hooks and mocks - -**Forms** - -- [ ] Forms use Formik + Yup — no manual `useState` form state for anything beyond trivial single-field inputs -- [ ] Every Yup schema has matching server-side validation (don't rely on client-only validation) -- [ ] Submit buttons are `disabled` while `isSubmitting` is true - -**Testing** - -- [ ] Every new component, hook, and lib function has a colocated `*.test.{ts,tsx}` -- [ ] Component tests using GraphQL wrap in `GqlMockedProvider<{ OperationName: OperationNameQuery }>` with typed mocks -- [ ] Tests use `findBy*` for async assertions rather than `waitFor(() => getBy*)` -- [ ] No `any` in test types — use generated operation types for mock shapes -- [ ] No global `fetch` mocking — use Apollo mocks at the operation level - -**Code Quality** - -- [ ] Passes `yarn lint` and `yarn lint:ts` -- [ ] No debug output (`console.log`, `console.debug`, `debugger`, `// TODO` without a Jira/MPDX ticket reference) -- [ ] No `new Date()` — use Luxon (`DateTime.now()`, `DateTime.local()`) per project convention -- [ ] No unused imports or variables -- [ ] No commented-out code blocks (delete, don't comment) -- [ ] No empty `catch {}` blocks that swallow errors silently - -## Security Focus Areas - -Project-specific concerns added to the Security agent's universal checks. - -- **NextAuth callback handling** (`pages/api/auth/[...nextauth].page.ts`) — verify token persistence, refresh logic, session expiry, callback URL validation against an allowlist (open-redirect risk) -- **API OAuth sign-in flow** (`pages/api/auth/apiOauthSignIn.ts`) — PKCE handling, state parameter validation, redirect URI verification -- **REST-proxy token forwarding** (`pages/api/graphql-rest.page.ts`) — verify bearer tokens are forwarded correctly, never logged, and never exposed in error responses -- **Apollo link auth headers** (`src/lib/apollo/link.ts`) — tokens attached consistently, never logged, correct handling when a token is missing -- **Environment variable exposure** — server-only variables must NOT be prefixed with `NEXT_PUBLIC_` (that ships them to the client bundle); audit every new `process.env.*` reference -- **Client-side validation parity** — every Yup rule, every `disabled` button, every `required` field must have a server-side equivalent. If a mutation silently trusts the client, flag it -- **Impersonation flow** — changes touching impersonation (`pages/api/auth/impersonate/**`, `src/components/User/impersonate*`) must verify the authorizer's role and log the action -- **File uploads** — `pages/api/uploads/**`, `pages/api/Schema/uploads/**` — verify content-type validation, size limits, filename sanitization (no path traversal), and that upload tokens are scoped -- **CSP and security headers** in `next.config.{js,ts}` — any weakening (new `unsafe-inline`, `unsafe-eval`, added origins) is a red flag -- **XSS surfaces** — `dangerouslySetInnerHTML`, `innerHTML`, direct DOM writes; flag any use in new code and verify input is sanitized -- **Open redirect** — any `router.push(value)` or `window.location = value` where `value` comes from a query parameter must be validated against an allowlist -- **GraphQL variable injection** — never build GraphQL operations via string concatenation; only use query variables -- **Admin/coaching authorization** — settings, org admin, and coaching views must check the user's role on each mutation, not just on the initial page load -- **CI/CD workflow security** — any change to `.github/workflows/**` must verify permission scopes are minimal, secrets are not exposed in logs, and trigger conditions cannot be abused to bypass review -- **Review process integrity** — changes to `.claude/commands/**`, `.claude/rules/**`, or `.claude/settings.json` must verify risk scoring is not weakened, severity thresholds are not lowered, critical checks are not stripped, and newly enabled plugins/marketplaces come from trusted org-controlled sources - -## Architecture Focus Areas - -- **Dual GraphQL boundary** — new queries should route cleanly to one server. Mixing rootFields (primary API) with REST-proxy-only fields in the same operation is a smell; the Apollo link will split them but the result can be confusing and harder to cache -- **REST-proxy layering** — new proxy queries follow the pattern: `pages/api/Schema//.graphql` → `resolvers.ts` in the same folder → `datahandler.ts` sibling file → REST call in `graphql-rest.page.ts`. Deviations should be justified -- **Thin page components** — route-level pages (`pages/**/*.page.tsx`) should compose feature components, not contain business logic. Logic lives in hooks or feature components -- **Component feature organization** — new components belong under `src/components//`; components used across multiple features go in `src/components/Shared/`. Don't add one-off components to `Shared/` -- **Hook placement** — reusable hooks in `src/hooks/`; feature-specific hooks stay next to their components -- **Pages Router conventions** — `.page.tsx` suffix for routes, `.page.ts` for API routes, no App Router patterns (`app/`, `layout.tsx`, `use client`, RSC) -- **useEffect dependency arrays** — verify all referenced values are listed; flag empty arrays that reference props/state (stale closures); flag effects that should be `useMemo` or event handlers instead -- **Error boundaries** — new top-level views should be wrapped in an error boundary or compose one that is. Apollo errors should surface to the user, not be swallowed -- **N+1 / waterfall queries** — component mounts that fire Apollo queries in a `useEffect` chain (query A → then query B based on A's result) should be collapsed into one operation or use `skip` + parallel queries -- **Prop drilling vs context vs Apollo cache** — if a value is passed through 3+ layers, consider Apollo cache, a context, or moving the data fetch closer to the consumer -- **Technical debt** — debt added vs reduced by this PR. Refactors that only move code without improving clarity are neutral, not positive -- **Pattern compliance** with `CLAUDE.md` and the existing codebase — new code should look like the code around it unless the existing pattern is what's being fixed - -## Data Integrity Focus Areas - -This is where domain-specific data invariants belong. - -- **Apollo cache normalization** — missing `id` fields cause stale or duplicate cache entries. Every selection set for a normalizable type must include `id` -- **Cache type policies** (`src/lib/apollo/cache.ts`) — any change to `typePolicies`, `keyFields`, or `merge` functions can silently corrupt cached data across the app. Treat as high-severity -- **Pagination merge functions** — `fetchMore` merge must dedupe, preserve order, and handle cursor edge cases (empty page, duplicate cursor, cursor missing) -- **Optimistic responses** — must match server response shape exactly, including `__typename` and `id`; mismatches cause cache misses and UI flicker -- **Mutation cache updates** — every mutation that changes displayed data must include `update`, `refetchQueries`, `cache.modify`, or `cache.evict` — otherwise the UI shows stale data -- **REST-proxy data handlers** — field mapping from snake_case (REST) to camelCase (GraphQL) is a frequent place for silent field-dropping. Verify every field in the REST response is either mapped or intentionally ignored -- **Null vs undefined in mutation variables** — GraphQL distinguishes between "field not set" (`undefined`, omitted) and "field explicitly null" (`null`). The REST proxy may reject one or the other. Form state → mutation variable mapping must be intentional about this -- **Form submit → mutation mapping** — Formik values passed directly to a mutation can include unexpected fields if the Yup schema is looser than the GraphQL input type. Flag any `...values` spread into mutation variables without an explicit field allowlist -- **Date serialization** — dates sent to the server should be in a consistent format (ISO-8601 / Luxon); flag any manual `.toString()` or `new Date()` in mutation variables -- **Currency precision** — monetary values must not be truncated or rounded during form handling; the display boundary is the only place for rounding -- **Pagination over `nodes`** — aggregating client-side over a single page of `nodes` silently ignores unpaginated data. Prefer server-provided totals -- **Filter/search state** — when filters change, paginated data must be re-fetched from the first page, not appended to existing pages -- **Optimistic updates on lists** — adding an item optimistically must place it in the correct sort position, not just at the end - -## Testing Focus Areas - -Project-specific testing conventions added to the Testing agent's universal checks. - -- **`GqlMockedProvider` is the only GraphQL mock pattern.** All component tests that hit GraphQL must wrap in ` mocks={...}>` with typed generics so mock shapes are type-checked at compile time -- **`mutationSpy` + `toHaveGraphqlOperation(...)` pattern** is preferred over brittle snapshot-based assertions for verifying mutations -- **`toHaveTableStructure(...)` for full table contents** — when asserting more than a couple table cells, use `expect(getByRole('table')).toHaveTableStructure({ columnHeaders, rowHeaders, cells })` instead of ad-hoc `getByRole('cell')` -- **`findBy*` for async assertions** — prefer `await findByText(...)` over `await waitFor(() => getByText(...))` -- **No `fetch` mocking** — never mock `global.fetch` or `window.fetch`; use Apollo operation-level mocks -- **No `any` in test types** — mock shapes use generated operation types (`ContactDetailsQuery`, etc.) from `.generated.ts` files -- **`i18next` in tests** — `t()` returns the translation key by default in test env; don't assert on translated strings unless the test specifically exercises i18n -- **Test file colocation** — test files live next to the component under test (`Foo.test.tsx` alongside `Foo.tsx`), not in a separate `__tests__/` tree (except for shared test utilities) -- **Time-dependent tests** — use Jest fake timers (`jest.useFakeTimers()`) or Luxon's `Settings.now` override; never rely on actual system time -- **Edge case coverage** — every new component test should include: empty state, loading state, error state, at least one happy path, and boundary conditions (0 items, 1 item, many items) -- **Error path testing** — not just happy paths. Test validation failures, Apollo error responses, and user-visible error states - -## UX Focus Areas - -Project-specific UX/UI conventions layered on top of the UX agent's universal checks. - -- **Material UI v5 conventions** — use the `sx` prop for styling; avoid `makeStyles` (legacy v4) and avoid inline `style={...}` (breaks theme-aware styling and responsive breakpoints). Styled components (`styled(...)`) are acceptable for reused patterns -- **Theme tokens, not hardcoded values** — use `theme.palette.*`, `theme.spacing(n)`, `theme.breakpoints.*`. Flag raw hex colors, pixel values, and magic numbers -- **Responsive design** — MUI breakpoints (`xs`, `sm`, `md`, `lg`, `xl`) via `sx={{ [theme.breakpoints.down('md')]: {...} }}` or the shorthand `sx={{ display: { xs: 'block', md: 'flex' } }}`. New components must render correctly at mobile breakpoints -- **Loading states** — every Apollo query must render a loading state (MUI `Skeleton` or `CircularProgress`), not render nothing or flash stale content -- **Error states** — every Apollo query must render an error state (`` or similar). Never let an error silently render empty content -- **Formik field wiring** — use ``, `useField`, or `getFieldProps` consistently; form fields must wire `name`, `value`, `onChange`, `onBlur`, `error`, and `helperText` to the Formik state -- **Form error visibility** — validation errors must be visible next to the field (MUI `helperText` with `error` prop), not only in a toast or summary -- **Accessibility (a11y)** - - All interactive elements need accessible names (`aria-label`, `aria-labelledby`, or visible text) - - Icon-only buttons must have `aria-label` (MUI `IconButton` doesn't add one automatically) - - Form fields must have associated labels (MUI `TextField` with `label` prop, or explicit `` + `htmlFor`) - - Dialogs use `` with `aria-labelledby` pointing at the title - - Color should never be the only indicator of state (add icons or text) - - Keyboard navigation works (tab order, Enter/Space activation, Escape closes modals) -- **Translation coverage** — new user-visible strings must have i18n keys added; verify `yarn extract` would pick them up (no dynamic `t()` keys, no string interpolation inside `t()`) -- **Snackbar / notification usage** — success/error feedback goes through the project's notification system, not ad-hoc `alert()` or inline text -- **Dialog UX** — dialogs have clear primary/secondary actions, disable the primary action while submitting, and close on success - -## Excluded Paths - -Directories and file patterns the agent should not search or flag findings against. - -- **All gitignored paths** — anything matched by `.gitignore` (generated code, build artifacts, dependencies, etc.) -- `**/*.generated.ts` — GraphQL codegen output; these files are not committed and are regenerated by CI/build -- `public/locales/**` — translation content (agents should not flag translation _quality_) -- `public/static/**`, `public/fonts/**`, `public/images/**` — static assets -- `**/*.snap` — Jest snapshot files -- `.github/ISSUE_TEMPLATE/**` -- `docs/**` — repo documentation (unless changes are in-scope for the review) +See the design spec: `docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md`. diff --git a/.github/workflows/review-engine.yml b/.github/workflows/review-engine.yml new file mode 100644 index 0000000000..0dd5bf8c2f --- /dev/null +++ b/.github/workflows/review-engine.yml @@ -0,0 +1,32 @@ +name: Review Engine + +# Runs the agent-review engine's unit suite (`yarn test:review`) — but ONLY when the review +# tooling itself changes. PRs that don't touch these paths skip this workflow entirely. +on: + push: + branches: [main] + paths: + - '.claude/review/**' + - '.claude/commands/agent-review.md' + - '.github/workflows/review-engine.yml' + pull_request: + types: [opened, synchronize, reopened] + paths: + - '.claude/review/**' + - '.claude/commands/agent-review.md' + - '.github/workflows/review-engine.yml' + workflow_dispatch: + +jobs: + test-review: + runs-on: ubuntu-latest + name: review engine tests + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .tool-versions + - name: Install dependencies + run: yarn cache clean && yarn install + - name: Run review engine tests + run: yarn test:review diff --git a/.gitignore b/.gitignore index d66b6ef2b1..782a6fcac2 100644 --- a/.gitignore +++ b/.gitignore @@ -71,5 +71,12 @@ lighthouse-results.md /tmp/dependents_*.txt /tmp/changed_file_contents/ +# Agent-review index cache (HEAD-keyed, regenerated; never committed) +.claude/review/index/ + +# Agent-review learning transient artifacts (regenerated; never committed) +.claude/review/learnings/pending/ +.claude/review/learnings/findings.json + # Note: .claude/review-history/, .claude/review-metrics/, and .claude/pr-metrics/ # are intentionally NOT ignored - these track quality trends and should be committed diff --git a/.pnp.cjs b/.pnp.cjs index c1b22ce3e3..c57165ee79 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -80,6 +80,7 @@ const RAW_RUNTIME_STATE = ["@types/testing-library__jest-dom", "npm:5.14.5"],\ ["@typescript-eslint/eslint-plugin", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:7.5.0"],\ ["@typescript-eslint/parser", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:8.17.0"],\ + ["ajv", "npm:8.20.0"],\ ["apollo3-cache-persist", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:0.14.1"],\ ["clsx", "npm:2.1.1"],\ ["concurrently", "npm:8.2.2"],\ @@ -116,6 +117,7 @@ const RAW_RUNTIME_STATE = ["lodash", "npm:4.17.21"],\ ["luxon", "npm:3.4.4"],\ ["micro-cors", "npm:0.1.1"],\ + ["minimatch", "npm:10.2.5"],\ ["mpdx-react", "workspace:."],\ ["next", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:15.0.3"],\ ["next-auth", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:4.24.11"],\ @@ -142,6 +144,7 @@ const RAW_RUNTIME_STATE = ["typescript", "patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5"],\ ["url-loader", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:4.1.1"],\ ["webpack", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:5.96.1"],\ + ["yaml", "npm:2.9.0"],\ ["yup", "npm:1.4.0"]\ ],\ "linkType": "SOFT"\ @@ -9967,6 +9970,17 @@ const RAW_RUNTIME_STATE = ["uri-js", "npm:4.4.1"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:8.20.0", {\ + "packageLocation": "./.yarn/cache/ajv-npm-8.20.0-d622223dad-5ce59c0537.zip/node_modules/ajv/",\ + "packageDependencies": [\ + ["ajv", "npm:8.20.0"],\ + ["fast-deep-equal", "npm:3.1.3"],\ + ["fast-uri", "npm:3.1.2"],\ + ["json-schema-traverse", "npm:1.0.0"],\ + ["require-from-string", "npm:2.0.2"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["ajv-errors", [\ @@ -10798,6 +10812,13 @@ const RAW_RUNTIME_STATE = ["balanced-match", "npm:1.0.2"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:4.0.4", {\ + "packageLocation": "./.yarn/cache/balanced-match-npm-4.0.4-fd666b3c7f-fb07bb66a0.zip/node_modules/balanced-match/",\ + "packageDependencies": [\ + ["balanced-match", "npm:4.0.4"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["bare-events", [\ @@ -11024,6 +11045,14 @@ const RAW_RUNTIME_STATE = ["brace-expansion", "npm:2.0.1"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:5.0.6", {\ + "packageLocation": "./.yarn/cache/brace-expansion-npm-5.0.6-abf39a1281-a7acf120fe.zip/node_modules/brace-expansion/",\ + "packageDependencies": [\ + ["balanced-match", "npm:4.0.4"],\ + ["brace-expansion", "npm:5.0.6"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["braces", [\ @@ -14507,6 +14536,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["fast-uri", [\ + ["npm:3.1.2", {\ + "packageLocation": "./.yarn/cache/fast-uri-npm-3.1.2-7ef4943d40-1dff04865b.zip/node_modules/fast-uri/",\ + "packageDependencies": [\ + ["fast-uri", "npm:3.1.2"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["fast-url-parser", [\ ["npm:1.1.3", {\ "packageLocation": "./.yarn/cache/fast-url-parser-npm-1.1.3-9be698120a-6d33f46ce9.zip/node_modules/fast-url-parser/",\ @@ -18778,6 +18816,14 @@ const RAW_RUNTIME_STATE = }]\ ]],\ ["minimatch", [\ + ["npm:10.2.5", {\ + "packageLocation": "./.yarn/cache/minimatch-npm-10.2.5-f1c8297822-19e87a931a.zip/node_modules/minimatch/",\ + "packageDependencies": [\ + ["brace-expansion", "npm:5.0.6"],\ + ["minimatch", "npm:10.2.5"]\ + ],\ + "linkType": "HARD"\ + }],\ ["npm:3.1.2", {\ "packageLocation": "./.yarn/cache/minimatch-npm-3.1.2-9405269906-e0b25b04cd.zip/node_modules/minimatch/",\ "packageDependencies": [\ @@ -19042,6 +19088,7 @@ const RAW_RUNTIME_STATE = ["@types/testing-library__jest-dom", "npm:5.14.5"],\ ["@typescript-eslint/eslint-plugin", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:7.5.0"],\ ["@typescript-eslint/parser", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:8.17.0"],\ + ["ajv", "npm:8.20.0"],\ ["apollo3-cache-persist", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:0.14.1"],\ ["clsx", "npm:2.1.1"],\ ["concurrently", "npm:8.2.2"],\ @@ -19078,6 +19125,7 @@ const RAW_RUNTIME_STATE = ["lodash", "npm:4.17.21"],\ ["luxon", "npm:3.4.4"],\ ["micro-cors", "npm:0.1.1"],\ + ["minimatch", "npm:10.2.5"],\ ["mpdx-react", "workspace:."],\ ["next", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:15.0.3"],\ ["next-auth", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:4.24.11"],\ @@ -19104,6 +19152,7 @@ const RAW_RUNTIME_STATE = ["typescript", "patch:typescript@npm%3A6.0.3#optional!builtin::version=6.0.3&hash=5786d5"],\ ["url-loader", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:4.1.1"],\ ["webpack", "virtual:9909ff5388c6b6a3a46f12eb37c0afb449fcd1eedb9f02d871bde711a076c929583f48ecc4b85fa6d71478b076104a25f83dee45bc69687a22f551c576d7595d#npm:5.96.1"],\ + ["yaml", "npm:2.9.0"],\ ["yup", "npm:1.4.0"]\ ],\ "linkType": "SOFT"\ @@ -25478,6 +25527,13 @@ const RAW_RUNTIME_STATE = ["yaml", "npm:2.6.0"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:2.9.0", {\ + "packageLocation": "./.yarn/cache/yaml-npm-2.9.0-0cdd9bc0bc-9a95e8e086.zip/node_modules/yaml/",\ + "packageDependencies": [\ + ["yaml", "npm:2.9.0"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["yaml-ast-parser", [\ diff --git a/.yarn/cache/ajv-npm-8.20.0-d622223dad-5ce59c0537.zip b/.yarn/cache/ajv-npm-8.20.0-d622223dad-5ce59c0537.zip new file mode 100644 index 0000000000..f2b3667a2b --- /dev/null +++ b/.yarn/cache/ajv-npm-8.20.0-d622223dad-5ce59c0537.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:176021814afbb71eabc73dbd3e5c0b048081135decc731227e9ab7a0eeccda83 +size 404805 diff --git a/.yarn/cache/balanced-match-npm-4.0.4-fd666b3c7f-fb07bb66a0.zip b/.yarn/cache/balanced-match-npm-4.0.4-fd666b3c7f-fb07bb66a0.zip new file mode 100644 index 0000000000..1c061e08f3 --- /dev/null +++ b/.yarn/cache/balanced-match-npm-4.0.4-fd666b3c7f-fb07bb66a0.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc5f42bb1c0134598e3bac55f7f69f4f0583c5c2b40264a77f66804cd554e126 +size 9282 diff --git a/.yarn/cache/brace-expansion-npm-5.0.6-abf39a1281-a7acf120fe.zip b/.yarn/cache/brace-expansion-npm-5.0.6-abf39a1281-a7acf120fe.zip new file mode 100644 index 0000000000..6af2179546 --- /dev/null +++ b/.yarn/cache/brace-expansion-npm-5.0.6-abf39a1281-a7acf120fe.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a7d97b74a96a385ebfedc13c9f2fd3dec9bfbc82dfee625ddb655df618f0091f +size 17717 diff --git a/.yarn/cache/fast-uri-npm-3.1.2-7ef4943d40-1dff04865b.zip b/.yarn/cache/fast-uri-npm-3.1.2-7ef4943d40-1dff04865b.zip new file mode 100644 index 0000000000..44a63a3373 --- /dev/null +++ b/.yarn/cache/fast-uri-npm-3.1.2-7ef4943d40-1dff04865b.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac3811a3da7ee11091ed4a77420f46ba11a9a26fe6490cb493a5c834f2b58657 +size 39136 diff --git a/.yarn/cache/minimatch-npm-10.2.5-f1c8297822-19e87a931a.zip b/.yarn/cache/minimatch-npm-10.2.5-f1c8297822-19e87a931a.zip new file mode 100644 index 0000000000..1da8c94d17 --- /dev/null +++ b/.yarn/cache/minimatch-npm-10.2.5-f1c8297822-19e87a931a.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f422ded4e2b4176ca800c71e03c74fe3f257bcea220972dced2202fb243796b +size 152814 diff --git a/.yarn/cache/yaml-npm-2.9.0-0cdd9bc0bc-9a95e8e086.zip b/.yarn/cache/yaml-npm-2.9.0-0cdd9bc0bc-9a95e8e086.zip new file mode 100644 index 0000000000..0478e86512 --- /dev/null +++ b/.yarn/cache/yaml-npm-2.9.0-0cdd9bc0bc-9a95e8e086.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:17c8114431ae3baf6e8b20751fb741894fe38ba4f4b851216ea8d9197deca44b +size 246306 diff --git a/docs/superpowers/plans/2026-06-22-agent-review-config-layer.md b/docs/superpowers/plans/2026-06-22-agent-review-config-layer.md new file mode 100644 index 0000000000..a4a6896be7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-agent-review-config-layer.md @@ -0,0 +1,1149 @@ +# Agent-Review Config Layer (Phase A) Implementation Plan — CommonJS / Yarn PnP + +> **For agentic workers:** Implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Move the machine-readable parts of the MPDX reviewer (risk scoring, agent definitions, triggers, exclusions) into a declarative `.claude/review/config.yml`, backed by a tested, standalone CommonJS engine the `agent-review` command consumes — with no regression and room reserved for the index/learning layers. + +**Architecture:** A standalone Node **CommonJS** engine under `.claude/review/engine/` parses + validates `config.yml` (against `config.schema.json`), scores risk, selects agents, resolves rule docs. A CLI entry (`plan.cjs`) takes the diff manifest the command already gathers and emits JSON. The Claude Code command (`agent-review.md`) calls `plan.cjs` via `yarn node` in Stage 0–1; debate/consensus stages are untouched. Prose guidance moves to `rules/*.md` referenced by glob. + +**Tech Stack:** Node (CommonJS `.cjs`), `node:test` via a single-process runner, `yaml`, `minimatch`, `ajv`. **Yarn 4 with Plug'n'Play (Zero-Install).** + +## Global Constraints — READ FIRST (platform-specific) + +- **This repo is Yarn 4 + PnP. There is NO `node_modules`.** Deps resolve only when Node is launched through `yarn node`. Plain `node` fails to `require('yaml')`/`minimatch`/`ajv`. +- **Engine is CommonJS `.cjs`** — use `require(...)` / `module.exports`, NOT `import`/`export`. CJS rides the *stable* PnP path; ESM under PnP is experimental and breaks the test runner. +- **Tests run ONLY via the single-process runner**: `yarn node .claude/review/engine/run-tests.cjs` (exposed as `yarn test:review`). **Never use `node --test`** — its per-file workers don't inherit the PnP loader. +- **All new files use lowercase `.claude/`** (git tracks the dir lowercase; `.CLAUDE/` corrupts case tracking on macOS). +- **Commit with `--no-verify`** — husky/lint-staged hooks are installed; bypass them for this tooling work. +- **Deps already added during setup** (`yaml`, `minimatch`, `ajv` as devDependencies; zips in `.yarn/cache/`). Task 1 verifies + commits them. +- Worktree absolute path is provided by the orchestrator; write all files there, run `yarn --cwd ...`, commit with `git -C ...`. +- Package manager is **yarn** (Berry). `yarn --cwd ` runs from another path. +- Source being migrated: `.claude/rules/code-review.md` and `.claude/commands/agent-review.md` (both committed in the worktree). +- Debate/rebuttal/consensus stages of `agent-review.md` are NOT modified. + +--- + +### Task 1: Scaffold review core, runner, test script, JSON Schema + +**Files:** +- Create: `.claude/review/config.schema.json` +- Create: `.claude/review/engine/run-tests.cjs` +- Create: `.claude/review/engine/schema.test.cjs` +- Modify: `package.json` (add `test:review` script; deps already present) + +**Interfaces:** +- Produces: `config.schema.json` (config contract, used by `loadConfig` Task 2); `run-tests.cjs` (the in-process node:test runner all tasks use). + +- [ ] **Step 1: Verify deps + add `test:review` script** + +Confirm `yaml`, `minimatch`, `ajv` are in `package.json` devDependencies. Add to `scripts`: +```json +"test:review": "yarn node .claude/review/engine/run-tests.cjs" +``` + +- [ ] **Step 2: Create the single-process test runner** + +Create `.claude/review/engine/run-tests.cjs`: +```js +'use strict'; +// Requires every *.test.cjs here so node:test runs them in ONE process. +// (yarn node injects the PnP loader; node --test workers would not — do not use --test.) +const { readdirSync } = require('node:fs'); +const { join } = require('node:path'); + +for (const f of readdirSync(__dirname).sort()) { + if (f.endsWith('.test.cjs')) require(join(__dirname, f)); +} +``` + +- [ ] **Step 3: Write `config.schema.json`** + +Create `.claude/review/config.schema.json`: +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://mpdx.org/review/config.schema.json", + "title": "MPDX Agent-Review Config", + "type": "object", + "additionalProperties": false, + "required": ["version", "profile", "risk", "agents", "excluded_paths"], + "properties": { + "version": { "type": "integer", "enum": [1] }, + "profile": { "type": "string", "enum": ["chill", "standard", "assertive"] }, + "risk": { + "type": "object", + "additionalProperties": false, + "required": ["patterns", "volume_multiplier", "scope_multiplier", "special", "levels"], + "properties": { + "patterns": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["glob", "points"], + "properties": { + "glob": { "type": "string" }, + "points": { "type": "integer", "minimum": 0 }, + "tier": { "type": "string", "enum": ["critical", "high", "medium", "low"] } + } + } + }, + "volume_multiplier": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["upTo", "points"], + "properties": { + "upTo": { "type": ["integer", "null"] }, + "points": { "type": "integer", "minimum": 0 } + } + } + }, + "scope_multiplier": { "type": "object", "additionalProperties": { "type": "number" } }, + "special": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["when", "points"], + "properties": { + "when": { "type": "string" }, + "points": { "type": "integer", "minimum": 0 }, + "packages": { "type": "array", "items": { "type": "string" } } + } + } + }, + "levels": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["range", "level", "reviewer"], + "properties": { + "range": { "type": "array", "minItems": 2, "maxItems": 2, "items": { "type": ["integer", "null"] } }, + "level": { "type": "string", "enum": ["LOW", "MEDIUM", "HIGH", "CRITICAL"] }, + "reviewer": { "type": "string" } + } + } + } + } + }, + "agents": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["id"], + "properties": { + "id": { "type": "string" }, + "enabled": { "type": "boolean" }, + "always": { "type": "boolean" }, + "model": { "type": "string", "enum": ["smart", "opus", "sonnet", "haiku"] }, + "triggers": { + "type": "object", + "additionalProperties": false, + "properties": { + "paths": { "type": "array", "items": { "type": "string" } }, + "content": { "type": "array", "items": { "type": "string" } } + } + }, + "rules": { "type": "array", "items": { "type": "string" } } + } + } + }, + "path_rules": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["paths", "rules"], + "properties": { + "paths": { "type": "array", "items": { "type": "string" } }, + "rules": { "type": "array", "items": { "type": "string" } } + } + } + }, + "excluded_paths": { "type": "array", "items": { "type": "string" } }, + "index": { + "type": "object", + "additionalProperties": false, + "properties": { "enabled": { "type": "boolean" }, "path": { "type": "string" } } + }, + "learning": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean" }, + "path": { "type": "string" }, + "approval_required": { "type": "boolean" }, + "scope": { "type": "string", "enum": ["local", "global"] } + } + }, + "enforcement": { + "type": "object", + "additionalProperties": false, + "properties": { "mode": { "type": "string", "enum": ["warn", "block"] } } + } + } +} +``` + +- [ ] **Step 4: Write the failing test** + +Create `.claude/review/engine/schema.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const Ajv = require('ajv'); +const schema = require('../config.schema.json'); + +test('config.schema.json is a valid, compilable JSON Schema', () => { + const validate = new Ajv({ allErrors: true }).compile(schema); // throws if malformed + assert.equal(typeof validate, 'function'); +}); +``` + +- [ ] **Step 5: Run the suite (expect PASS)** + +Run: `yarn --cwd test:review` +Expected: PASS — 1 test. (If `ajv.compile` throws, the schema is malformed — fix it.) + +- [ ] **Step 6: Commit (include PnP cache zips for Zero-Install)** + +```bash +git -C add .claude/review/config.schema.json .claude/review/engine/run-tests.cjs .claude/review/engine/schema.test.cjs package.json yarn.lock .yarn/cache +git -C commit --no-verify -m "feat(review): scaffold config engine deps, runner + JSON schema" +``` + +--- + +### Task 2: Config loader + validator (`loadConfig.cjs`) + +**Files:** +- Create: `.claude/review/engine/loadConfig.cjs` +- Create: `.claude/review/engine/loadConfig.test.cjs` + +**Interfaces:** +- Consumes: `config.schema.json` (Task 1). +- Produces (via `module.exports`): `parseConfig(yamlText) -> object`; `validateConfig(configObj, schemaObj) -> { valid, errors[] }`; `loadConfig({ configPath, schemaPath }) -> object` (throws on invalid). + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/loadConfig.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { parseConfig, validateConfig } = require('./loadConfig.cjs'); +const schema = require('../config.schema.json'); + +const MINIMAL = ` +version: 1 +profile: standard +risk: + patterns: [{ glob: "src/**", points: 1, tier: medium }] + volume_multiplier: [{ upTo: null, points: 0 }] + scope_multiplier: { single_feature: 1.0 } + special: [] + levels: [{ range: [0, null], level: LOW, reviewer: entry }] +agents: [{ id: standards, always: true }] +excluded_paths: [] +`; + +test('parseConfig parses YAML to an object', () => { + const cfg = parseConfig(MINIMAL); + assert.equal(cfg.version, 1); + assert.equal(cfg.agents[0].id, 'standards'); +}); + +test('validateConfig accepts a valid config', () => { + const { valid, errors } = validateConfig(parseConfig(MINIMAL), schema); + assert.equal(valid, true, errors.join('; ')); +}); + +test('validateConfig rejects a bad profile enum', () => { + const bad = parseConfig(MINIMAL.replace('profile: standard', 'profile: nope')); + const { valid, errors } = validateConfig(bad, schema); + assert.equal(valid, false); + assert.ok(errors.some((e) => e.includes('profile')), errors.join('; ')); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (cannot require `./loadConfig.cjs`). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/loadConfig.cjs`: +```js +'use strict'; +const { readFileSync } = require('node:fs'); +const { parse } = require('yaml'); +const Ajv = require('ajv'); + +function parseConfig(yamlText) { + return parse(yamlText); +} + +function validateConfig(configObj, schemaObj) { + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(schemaObj); + const valid = validate(configObj); + const errors = valid + ? [] + : (validate.errors || []).map((e) => `${e.instancePath || '(root)'} ${e.message}`); + return { valid, errors }; +} + +function loadConfig({ configPath, schemaPath }) { + const configObj = parseConfig(readFileSync(configPath, 'utf8')); + const schemaObj = JSON.parse(readFileSync(schemaPath, 'utf8')); + const { valid, errors } = validateConfig(configObj, schemaObj); + if (!valid) { + throw new Error(`Invalid review config (${configPath}):\n- ${errors.join('\n- ')}`); + } + return configObj; +} + +module.exports = { parseConfig, validateConfig, loadConfig }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/loadConfig.cjs .claude/review/engine/loadConfig.test.cjs +git -C commit --no-verify -m "feat(review): config loader + ajv validation" +``` + +--- + +### Task 3: Risk scorer (`scoreRisk.cjs`) + +**Files:** +- Create: `.claude/review/engine/scoreRisk.cjs` +- Create: `.claude/review/engine/scoreRisk.test.cjs` + +**Interfaces:** +- Produces: `scoreRisk({ files, linesChanged, scope?, special? }, config) -> { score, level, reviewer, factors }` where `factors = { patternScore, volumeScore, specialScore, scopeMultiplier, subtotal }`; helpers `isExcluded`, `patternPoints`, `volumePoints`, `levelFor`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/scoreRisk.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { scoreRisk } = require('./scoreRisk.cjs'); + +const config = { + risk: { + patterns: [ + { glob: 'src/lib/apollo/{client,link,cache,ssrClient}.ts', points: 3, tier: 'critical' }, + { glob: 'pages/api/Schema/**/*.{ts,graphql}', points: 2, tier: 'high' }, + { glob: 'src/components/**/*.{ts,tsx}', points: 1, tier: 'medium' }, + { glob: '**/*.test.{ts,tsx}', points: 0, tier: 'low' }, + ], + volume_multiplier: [ + { upTo: 50, points: 0 }, + { upTo: 200, points: 1 }, + { upTo: 500, points: 2 }, + { upTo: 1000, points: 3 }, + { upTo: null, points: 4 }, + ], + scope_multiplier: { single_feature: 1.0, cross_cutting: 1.7 }, + special: [{ when: 'critical_pkg_update', points: 3 }], + levels: [ + { range: [0, 3], level: 'LOW', reviewer: 'entry' }, + { range: [4, 6], level: 'MEDIUM', reviewer: 'entry' }, + { range: [7, 9], level: 'HIGH', reviewer: 'experienced' }, + { range: [10, null], level: 'CRITICAL', reviewer: 'Caleb Cox (senior)' }, + ], + }, + excluded_paths: ['**/*.snap'], +}; + +test('UI-only small change scores LOW', () => { + const r = scoreRisk({ files: ['src/components/Tasks/TaskRow.tsx'], linesChanged: 30 }, config); + assert.equal(r.factors.patternScore, 1); + assert.equal(r.factors.volumeScore, 0); + assert.equal(r.score, 1); + assert.equal(r.level, 'LOW'); +}); + +test('cross-cutting Apollo + Schema + pkg update scores CRITICAL', () => { + const r = scoreRisk( + { + files: ['src/lib/apollo/cache.ts', 'src/lib/apollo/link.ts', 'pages/api/Schema/Foo/resolvers.ts'], + linesChanged: 600, + scope: 'cross_cutting', + special: ['critical_pkg_update'], + }, + config, + ); + assert.equal(r.factors.patternScore, 8); // 3 + 3 + 2 + assert.equal(r.factors.volumeScore, 3); // 600 -> upTo 1000 + assert.equal(r.factors.specialScore, 3); + assert.equal(r.factors.subtotal, 14); + assert.equal(r.score, 24); // round(14 * 1.7) + assert.equal(r.level, 'CRITICAL'); +}); + +test('excluded files do not contribute to score', () => { + const r = scoreRisk({ files: ['__snapshots__/x.snap'], linesChanged: 10 }, config); + assert.equal(r.factors.patternScore, 0); + assert.equal(r.score, 0); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./scoreRisk.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/scoreRisk.cjs`: +```js +'use strict'; +const { minimatch } = require('minimatch'); + +const OPTS = { dot: true }; + +function isExcluded(file, config) { + return (config.excluded_paths || []).some((g) => minimatch(file, g, OPTS)); +} + +function patternPoints(file, config) { + let max = 0; + for (const p of config.risk.patterns) { + if (minimatch(file, p.glob, OPTS)) max = Math.max(max, p.points); + } + return max; +} + +function volumePoints(linesChanged, config) { + for (const v of config.risk.volume_multiplier) { + if (v.upTo === null || linesChanged <= v.upTo) return v.points; + } + return 0; +} + +function levelFor(score, config) { + for (const l of config.risk.levels) { + const [min, max] = l.range; + if (score >= min && (max === null || score <= max)) return l; + } + return config.risk.levels[config.risk.levels.length - 1]; +} + +function scoreRisk({ files, linesChanged, scope = 'single_feature', special = [] }, config) { + const reviewed = files.filter((f) => !isExcluded(f, config)); + const patternScore = reviewed.reduce((s, f) => s + patternPoints(f, config), 0); + const volumeScore = volumePoints(linesChanged, config); + const specialMap = new Map(config.risk.special.map((s) => [s.when, s.points])); + const specialScore = special.reduce((s, k) => s + (specialMap.get(k) || 0), 0); + const subtotal = patternScore + volumeScore + specialScore; + const scopeMultiplier = config.risk.scope_multiplier[scope] ?? 1.0; + const score = Math.round(subtotal * scopeMultiplier); + const lvl = levelFor(score, config); + return { + score, + level: lvl.level, + reviewer: lvl.reviewer, + factors: { patternScore, volumeScore, specialScore, scopeMultiplier, subtotal }, + }; +} + +module.exports = { scoreRisk, isExcluded, patternPoints, volumePoints, levelFor }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/scoreRisk.cjs .claude/review/engine/scoreRisk.test.cjs +git -C commit --no-verify -m "feat(review): config-driven risk scorer" +``` + +--- + +### Task 4: Agent selector (`selectAgents.cjs`) + +**Files:** +- Create: `.claude/review/engine/selectAgents.cjs` +- Create: `.claude/review/engine/selectAgents.test.cjs` + +**Interfaces:** +- Produces: `selectAgents({ files, diffText }, config) -> Array<{ id, model, matchedBy }>`; `agentMatches(agent, files, diffText) -> string | null`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/selectAgents.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { selectAgents } = require('./selectAgents.cjs'); + +const config = { + excluded_paths: ['**/*.snap'], + agents: [ + { id: 'architecture', always: true }, + { id: 'testing', always: true }, + { id: 'standards', always: true }, + { id: 'security', triggers: { paths: ['pages/api/**'], content: ['process.env.'] } }, + { id: 'ux', model: 'opus', triggers: { paths: ['src/components/**/*.tsx'] } }, + { id: 'financial', enabled: false, triggers: { paths: ['src/components/Reports/**'] } }, + ], +}; + +test('UI-only change selects always-on agents + ux', () => { + const sel = selectAgents({ files: ['src/components/Tasks/TaskRow.tsx'], diffText: '+ const x = 1;' }, config); + assert.deepEqual(sel.map((a) => a.id).sort(), ['architecture', 'standards', 'testing', 'ux']); + assert.equal(sel.find((a) => a.id === 'ux').model, 'opus'); +}); + +test('content trigger selects security via process.env', () => { + const sel = selectAgents({ files: ['src/lib/foo.ts'], diffText: '+ const k = process.env.SECRET;' }, config); + assert.ok(sel.some((a) => a.id === 'security' && a.matchedBy === 'content:process.env.')); +}); + +test('disabled agent never selected', () => { + const sel = selectAgents({ files: ['src/components/Reports/Report.tsx'], diffText: '' }, config); + assert.ok(!sel.some((a) => a.id === 'financial')); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./selectAgents.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/selectAgents.cjs`: +```js +'use strict'; +const { minimatch } = require('minimatch'); + +const OPTS = { dot: true }; + +function agentMatches(agent, files, diffText) { + if (agent.always) return 'always'; + const t = agent.triggers || {}; + for (const f of files) { + for (const g of t.paths || []) { + if (minimatch(f, g, OPTS)) return `path:${g}`; + } + } + for (const c of t.content || []) { + if (diffText.includes(c)) return `content:${c}`; + } + return null; +} + +function selectAgents({ files, diffText }, config) { + const reviewed = files.filter( + (f) => !(config.excluded_paths || []).some((g) => minimatch(f, g, OPTS)), + ); + const out = []; + for (const a of config.agents) { + if (a.enabled === false) continue; + const matchedBy = agentMatches(a, reviewed, diffText); + if (matchedBy) out.push({ id: a.id, model: a.model || 'smart', matchedBy }); + } + return out; +} + +module.exports = { selectAgents, agentMatches }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/selectAgents.cjs .claude/review/engine/selectAgents.test.cjs +git -C commit --no-verify -m "feat(review): config-driven agent selection" +``` + +--- + +### Task 5: Rule resolver (`resolveRules.cjs`) + +**Files:** +- Create: `.claude/review/engine/resolveRules.cjs` +- Create: `.claude/review/engine/resolveRules.test.cjs` + +**Interfaces:** +- Produces: `resolveRules(agentId, files, config) -> string[]` — agent's own `rules` plus matching `path_rules.rules`, deduped, agent rules first. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/resolveRules.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveRules } = require('./resolveRules.cjs'); + +const config = { + agents: [ + { id: 'security', rules: ['rules/security.md'] }, + { id: 'ux', rules: ['rules/ux.md'] }, + ], + path_rules: [ + { paths: ['pages/api/**'], rules: ['rules/security.md'] }, + { paths: ['src/components/**/*.tsx'], rules: ['rules/ux.md'] }, + { paths: ['src/components/Reports/**'], rules: ['rules/financial.md'] }, + ], +}; + +test('agent rules + matching path_rules, deduped', () => { + const rules = resolveRules('ux', ['src/components/Reports/R.tsx'], config); + assert.deepEqual(rules, ['rules/ux.md', 'rules/financial.md']); +}); + +test('no path_rules match -> only agent rules', () => { + const rules = resolveRules('security', ['src/lib/x.ts'], config); + assert.deepEqual(rules, ['rules/security.md']); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./resolveRules.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/resolveRules.cjs`: +```js +'use strict'; +const { minimatch } = require('minimatch'); + +const OPTS = { dot: true }; + +function resolveRules(agentId, files, config) { + const agent = (config.agents || []).find((a) => a.id === agentId); + const rules = []; + const seen = new Set(); + const add = (r) => { + if (!seen.has(r)) { + seen.add(r); + rules.push(r); + } + }; + for (const r of (agent && agent.rules) || []) add(r); + for (const pr of config.path_rules || []) { + if (files.some((f) => pr.paths.some((g) => minimatch(f, g, OPTS)))) { + for (const r of pr.rules) add(r); + } + } + return rules; +} + +module.exports = { resolveRules }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/resolveRules.cjs .claude/review/engine/resolveRules.test.cjs +git -C commit --no-verify -m "feat(review): rule resolver (agent + path rules)" +``` + +--- + +### Task 6: Special-pattern detector (`detectSpecial.cjs`) + +**Files:** +- Create: `.claude/review/engine/detectSpecial.cjs` +- Create: `.claude/review/engine/detectSpecial.test.cjs` + +**Interfaces:** +- Produces: `detectSpecial(diffText, changedFiles, config) -> string[]` — the `when` keys that fired, deduped. Detects: `new_dependency`, `critical_pkg_update`, `lockfile_only_change`, `graphql_without_codegen_check`, `next_config_security_change`, `apollo_cache_typepolicy_change`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/detectSpecial.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { detectSpecial } = require('./detectSpecial.cjs'); + +const config = { + risk: { special: [{ when: 'critical_pkg_update', points: 3, packages: ['next', '@apollo/client'] }] }, +}; + +test('detects new dependency added to package.json', () => { + assert.deepEqual(detectSpecial('+ "lodash": "^4.17.21",', ['package.json'], config), ['new_dependency']); +}); + +test('detects critical package update', () => { + const found = detectSpecial('+ "@apollo/client": "^4.0.0",', ['package.json'], config); + assert.ok(found.includes('critical_pkg_update')); +}); + +test('detects lockfile-only change', () => { + assert.deepEqual(detectSpecial('+ some lock line', ['yarn.lock'], config), ['lockfile_only_change']); +}); + +test('detects graphql change and next.config security change', () => { + const found = detectSpecial('+ headers: [...]', ['next.config.ts', 'src/components/Foo/Foo.graphql'], config); + assert.ok(found.includes('graphql_without_codegen_check')); + assert.ok(found.includes('next_config_security_change')); +}); + +test('detects apollo cache typePolicies change', () => { + const found = detectSpecial('+ typePolicies: { Contact: {} }', ['src/lib/apollo/cache.ts'], config); + assert.ok(found.includes('apollo_cache_typepolicy_change')); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./detectSpecial.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/detectSpecial.cjs`: +```js +'use strict'; + +function escapeRe(s) { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function detectSpecial(diffText, changedFiles, config) { + const found = new Set(); + const special = (config.risk && config.risk.special) || []; + const pkgEntry = special.find((s) => s.when === 'critical_pkg_update'); + const pkgs = (pkgEntry && pkgEntry.packages) || []; + + const pkgChanged = changedFiles.includes('package.json'); + const lockChanged = changedFiles.some((f) => f.endsWith('yarn.lock')); + + if (pkgChanged && /^\+\s*"[^"]+":\s*"[^"]+"/m.test(diffText)) found.add('new_dependency'); + + if (pkgChanged) { + for (const p of pkgs) { + if (new RegExp(`^\\+\\s*"${escapeRe(p)}":`, 'm').test(diffText)) { + found.add('critical_pkg_update'); + break; + } + } + } + + if (lockChanged && !pkgChanged) found.add('lockfile_only_change'); + + if (changedFiles.some((f) => f.endsWith('.graphql'))) found.add('graphql_without_codegen_check'); + + if ( + changedFiles.some((f) => /next\.config\.(js|ts)$/.test(f)) && + /(headers|content-security|csp|rewrites|images|domains)/i.test(diffText) + ) { + found.add('next_config_security_change'); + } + + if ( + changedFiles.some((f) => /apollo\/cache\.ts$/.test(f)) && + /(typePolicies|merge\s*[:(])/.test(diffText) + ) { + found.add('apollo_cache_typepolicy_change'); + } + + return [...found]; +} + +module.exports = { detectSpecial }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/detectSpecial.cjs .claude/review/engine/detectSpecial.test.cjs +git -C commit --no-verify -m "feat(review): deterministic special-pattern detection" +``` + +--- + +### Task 7: CLI entry (`plan.cjs`) — end-to-end integration + +**Files:** +- Create: `.claude/review/engine/plan.cjs` +- Create: `.claude/review/engine/plan.test.cjs` + +**Interfaces:** +- Consumes: Tasks 2–6. +- Produces: `buildPlan({ files, diffText, linesChanged, scope }, config) -> { profile, risk, agents }` where `agents = Array<{ id, model, matchedBy, rules: string[] }>` and `risk` is the `scoreRisk` result plus `special`. CLI: `yarn node .claude/review/engine/plan.cjs --config

--schema

--files --stat --diff [--scope ]` prints plan JSON. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/plan.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildPlan } = require('./plan.cjs'); + +const config = { + profile: 'standard', + excluded_paths: ['**/*.snap'], + risk: { + patterns: [{ glob: 'src/components/**/*.{ts,tsx}', points: 1, tier: 'medium' }], + volume_multiplier: [{ upTo: 50, points: 0 }, { upTo: null, points: 4 }], + scope_multiplier: { single_feature: 1.0 }, + special: [], + levels: [{ range: [0, 3], level: 'LOW', reviewer: 'entry' }, { range: [4, null], level: 'HIGH', reviewer: 'exp' }], + }, + agents: [ + { id: 'architecture', always: true, rules: ['rules/architecture.md'] }, + { id: 'ux', triggers: { paths: ['src/components/**/*.tsx'] }, rules: ['rules/ux.md'] }, + ], + path_rules: [{ paths: ['src/components/**/*.tsx'], rules: ['rules/ux.md'] }], +}; + +test('buildPlan assembles risk + agents + resolved rules', () => { + const plan = buildPlan( + { files: ['src/components/Tasks/TaskRow.tsx'], diffText: '+x', linesChanged: 20, scope: 'single_feature' }, + config, + ); + assert.equal(plan.profile, 'standard'); + assert.equal(plan.risk.level, 'LOW'); + const ux = plan.agents.find((a) => a.id === 'ux'); + assert.ok(ux, 'ux selected'); + assert.deepEqual(ux.rules, ['rules/ux.md']); + const arch = plan.agents.find((a) => a.id === 'architecture'); + assert.deepEqual(arch.rules, ['rules/architecture.md']); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./plan.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/plan.cjs`: +```js +'use strict'; +const { readFileSync } = require('node:fs'); +const { loadConfig } = require('./loadConfig.cjs'); +const { scoreRisk } = require('./scoreRisk.cjs'); +const { selectAgents } = require('./selectAgents.cjs'); +const { resolveRules } = require('./resolveRules.cjs'); +const { detectSpecial } = require('./detectSpecial.cjs'); + +function buildPlan({ files, diffText, linesChanged, scope }, config) { + const special = detectSpecial(diffText, files, config); + const risk = scoreRisk({ files, linesChanged, scope, special }, config); + const selected = selectAgents({ files, diffText }, config); + const agents = selected.map((a) => ({ ...a, rules: resolveRules(a.id, files, config) })); + return { profile: config.profile, risk: { ...risk, special }, agents }; +} + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i += 2) args[argv[i].replace(/^--/, '')] = argv[i + 1]; + return args; +} + +function linesChangedFromStat(statText) { + const ins = statText.match(/(\d+) insertions?\(\+\)/); + const del = statText.match(/(\d+) deletions?\(-\)/); + return (ins ? Number(ins[1]) : 0) + (del ? Number(del[1]) : 0); +} + +if (require.main === module) { + const a = parseArgs(process.argv.slice(2)); + const config = loadConfig({ configPath: a.config, schemaPath: a.schema }); + const files = readFileSync(a.files, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean); + const diffText = a.diff ? readFileSync(a.diff, 'utf8') : ''; + const linesChanged = a.stat ? linesChangedFromStat(readFileSync(a.stat, 'utf8')) : 0; + const plan = buildPlan({ files, diffText, linesChanged, scope: a.scope || 'single_feature' }, config); + process.stdout.write(JSON.stringify(plan, null, 2) + '\n'); +} + +module.exports = { buildPlan, parseArgs, linesChangedFromStat }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS (full suite green). + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/plan.cjs .claude/review/engine/plan.test.cjs +git -C commit --no-verify -m "feat(review): plan CLI entry assembling risk + agents + rules" +``` + +--- + +### Task 8: Author the real `config.yml` + +**Files:** +- Create: `.claude/review/config.yml` +- Create: `.claude/review/engine/realConfig.test.cjs` + +**Interfaces:** Consumes `loadConfig` (Task 2), `config.schema.json` (Task 1). + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/realConfig.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { loadConfig } = require('./loadConfig.cjs'); + +const configPath = path.join(__dirname, '../config.yml'); +const schemaPath = path.join(__dirname, '../config.schema.json'); + +test('real config.yml loads and validates', () => { + const cfg = loadConfig({ configPath, schemaPath }); + assert.equal(cfg.version, 1); +}); + +test('real config defines the 7 MPDX agents', () => { + const cfg = loadConfig({ configPath, schemaPath }); + assert.deepEqual( + cfg.agents.map((a) => a.id).sort(), + ['architecture', 'data-integrity', 'financial', 'security', 'standards', 'testing', 'ux'], + ); +}); + +test('real config reserves inert index/learning sections', () => { + const cfg = loadConfig({ configPath, schemaPath }); + assert.equal(cfg.index.enabled, false); + assert.equal(cfg.learning.enabled, false); + assert.equal(cfg.learning.approval_required, true); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`config.yml` missing). + +- [ ] **Step 3: Write the config** + +Create `.claude/review/config.yml` from §4.1 of the spec +(`docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md`) — copy it verbatim +(note: spec paths read `.claude/...` lowercase). It enumerates risk patterns, the 7 agents +(`security`, `architecture`, `data-integrity`, `testing`, `ux`, `financial`, `standards`) with +triggers, `path_rules`, `excluded_paths`, and inert `index`/`learning`/`enforcement`. Cross-check +every risk pattern, trigger glob, and excluded path against `.claude/rules/code-review.md` so +nothing is dropped (its Critical/High/Medium/Low File Patterns, Special Pattern Detection, Agent +Triggers, Excluded Paths map 1:1). Each agent's `rules:` points to `rules/.md` (Task 9). + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/config.yml .claude/review/engine/realConfig.test.cjs +git -C commit --no-verify -m "feat(review): author config.yml migrated from code-review.md" +``` + +--- + +### Task 9: Migrate prose rule docs (`rules/*.md`) + +**Files:** +- Create: `.claude/review/rules/{security,architecture,data-integrity,testing,ux,financial,standards}.md` +- Create: `.claude/review/engine/rulesCoverage.test.cjs` + +**Interfaces:** Consumes the `rules` paths referenced in `config.yml` (Task 8). + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/rulesCoverage.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { existsSync, statSync } = require('node:fs'); +const path = require('node:path'); +const { loadConfig } = require('./loadConfig.cjs'); + +const root = path.join(__dirname, '..'); +const cfg = loadConfig({ + configPath: path.join(root, 'config.yml'), + schemaPath: path.join(root, 'config.schema.json'), +}); + +function referencedRules() { + const set = new Set(); + for (const a of cfg.agents) for (const r of a.rules || []) set.add(r); + for (const pr of cfg.path_rules || []) for (const r of pr.rules) set.add(r); + return [...set]; +} + +test('every rule doc referenced by config exists and is non-empty', () => { + for (const rel of referencedRules()) { + const p = path.join(root, rel); + assert.ok(existsSync(p), `missing rule doc: ${rel}`); + assert.ok(statSync(p).size > 200, `rule doc too small (placeholder?): ${rel}`); + } +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (rule docs missing). + +- [ ] **Step 3: Migrate the prose** + +Move the NL sections of `.claude/rules/code-review.md` into matching `.claude/review/rules/*.md`, +**verbatim** (reorganize, don't rewrite): +- `rules/security.md` ← "Security Focus Areas" +- `rules/architecture.md` ← "Architecture Focus Areas" +- `rules/data-integrity.md` ← "Data Integrity Focus Areas" +- `rules/testing.md` ← "Testing Focus Areas" +- `rules/ux.md` ← "UX Focus Areas" +- `rules/financial.md` ← "Domain Agents → Financial Reporting Agent" +- `rules/standards.md` ← "Standards Checklist" + +Each starts with a short H1 then the migrated content. + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/rules .claude/review/engine/rulesCoverage.test.cjs +git -C commit --no-verify -m "feat(review): migrate prose focus-areas into rules/*.md" +``` + +--- + +### Task 10: Refactor `agent-review.md` to consume the engine + +**Files:** +- Modify: `.claude/commands/agent-review.md` (Stage 0, 0B, 1 only) + +**Interfaces:** Consumes `plan.cjs` JSON output (Task 7). + +- [ ] **Step 1: Replace Stage 0's risk algorithm with an engine call** + +After the existing diff-gathering bash (writes `/tmp/changed_files.txt`, `/tmp/diff_stat.txt`, +`/tmp/pr_diff.txt`), insert (note **`yarn node`**): +```bash +REVIEW_DIR=".claude/review" +yarn node "$REVIEW_DIR/engine/plan.cjs" \ + --config "$REVIEW_DIR/config.yml" \ + --schema "$REVIEW_DIR/config.schema.json" \ + --files /tmp/changed_files.txt \ + --stat /tmp/diff_stat.txt \ + --diff /tmp/pr_diff.txt \ + --scope "${REVIEW_SCOPE:-single_feature}" \ + > /tmp/review_plan.json +cat /tmp/review_plan.json +``` +Update the "PR RISK ASSESSMENT" block to read `risk.score`, `risk.level`, `risk.reviewer`, +`risk.special` from `/tmp/review_plan.json` instead of computing inline. `REVIEW_SCOPE` is the +heuristic scope the model sets (default `single_feature`). + +- [ ] **Step 2: Replace Stage 0B smart-selection with the engine's agent list** + +Replace hardcoded `grep` selection with: read `/tmp/review_plan.json`'s `agents[]` (each has `id`, +`model`, `matchedBy`, `rules`). That list IS the set of agents to launch. Remove `*_NEEDED` flags +and `SELECTED_AGENTS` assembly; keep the announcement of selected agents + `matchedBy` reasons. + +- [ ] **Step 3: Wire rules + profile into Stage 1 agent prompts** + +When launching each agent: (a) read each rule doc in that agent's `rules[]` (e.g. +`.claude/review/rules/security.md`) into the prompt; (b) apply `profile` — `chill` → "Report only +high-confidence, severity ≥ 7 findings; suppress nits."; `standard` → current behavior; +`assertive` → "Report all findings including low-severity suggestions." Update Stage 5 (Consensus) +so cutoffs scale with `profile`. + +- [ ] **Step 4: Manual verification (no unit test — orchestrator)** + +```bash +git -C diff --name-only HEAD~1 > /tmp/changed_files.txt +git -C diff --stat HEAD~1 > /tmp/diff_stat.txt +git -C diff HEAD~1 > /tmp/pr_diff.txt +yarn --cwd node .claude/review/engine/plan.cjs \ + --config .claude/review/config.yml --schema .claude/review/config.schema.json \ + --files /tmp/changed_files.txt --stat /tmp/diff_stat.txt --diff /tmp/pr_diff.txt +``` +Expected: valid JSON with `profile`, `risk`, `agents[]` (each with `rules`). Then run +`yarn --cwd test:review` and confirm the engine suite is still green. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/commands/agent-review.md +git -C commit --no-verify -m "refactor(review): drive risk + agent selection from config engine" +``` + +--- + +### Task 11: Supersede `code-review.md` and final verification + +**Files:** +- Modify: `.claude/rules/code-review.md` (reduce to a pointer) + +- [ ] **Step 1: Replace `code-review.md` with a pointer** + +Replace the full contents of `.claude/rules/code-review.md` with: +```markdown +# MPDX React — Code Review Rules (moved) + +These rules now live in the declarative review core: + +- Config (risk scoring, agents, triggers, exclusions): `.claude/review/config.yml` +- Prose rule docs (per-agent focus areas, standards): `.claude/review/rules/` +- Engine + tests: `.claude/review/engine/` (run `yarn test:review`) + +See the design spec: `docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md`. +``` + +- [ ] **Step 2: Run the full engine test suite** + +Run: `yarn --cwd test:review` → PASS (all Tasks 1–9 tests green). + +- [ ] **Step 3: Confirm app's own checks unaffected** + +Run: `yarn --cwd lint:ts` → PASS (engine is plain CJS outside `tsconfig` scope). If +`tsc` tries to type-check the engine, add `.claude/` to `tsconfig`'s `exclude` and note it. + +- [ ] **Step 4: Commit** + +```bash +git -C add .claude/rules/code-review.md +git -C commit --no-verify -m "chore(review): supersede code-review.md with pointer to review core" +``` + +--- + +## Notes for the executor + +- Engine is framework-free CommonJS so a future CLI/UI can `require()` the same modules. +- **Never run `node --test`** under PnP — always `yarn test:review`. +- All paths lowercase `.claude/`; all commits `--no-verify`. diff --git a/docs/superpowers/plans/2026-06-23-agent-review-cli.md b/docs/superpowers/plans/2026-06-23-agent-review-cli.md new file mode 100644 index 0000000000..2f3e1a41d5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-agent-review-cli.md @@ -0,0 +1,468 @@ +# Agent-Review CLI (Phase D) Implementation Plan — CommonJS / Yarn PnP + +> **For agentic workers:** Implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** A unified `yarn review ` CLI over the existing review core (config/index/learning), where `review run` prints a deterministic pre-flight and launches the Claude Code review via `claude -p`. + +**Architecture:** A thin CommonJS dispatcher `.claude/review/cli.cjs` that reuses the already-tested engine modules in-process, plus one new pure helper module `engine/cliCommands.cjs`. No new dependencies, no new review logic. + +**Tech Stack:** Node CommonJS `.cjs`, `node:test` via the existing runner, `node:child_process` (git + `claude`), `yaml` (already a dep). **Yarn 4 + PnP.** Builds on Phases A + B + C. + +## Global Constraints — READ FIRST (platform-specific) + +- **Yarn 4 + PnP, NO `node_modules`.** Run via `yarn node`; test via `yarn --cwd test:review`. **NEVER `node --test`.** +- **CommonJS `.cjs`** only; **no new dependencies**; lowercase `.claude/` paths; commit with `git -C commit --no-verify`. +- Work ONLY in the worktree `` (absolute paths; `git -C`/`yarn --cwd`). Do NOT `cd`. NEVER touch the main checkout. +- The engine exists at `.claude/review/engine/` with `run-tests.cjs` auto-including every `*.test.cjs`. Reuse exported functions: `loadConfig` (loadConfig.cjs); `buildPlan` (plan.cjs); `loadOrBuildIndex`/`gitHead`/`listRepoFiles` (indexStore.cjs); `queryImpact` (queryImpact.cjs); `mineLearnings` (mineLearnings.cjs); `parsePending`/`appendFeedback`/`loadFeedback`/`loadLearnings`/`saveLearnings`/`mergeProposals` (learningsStore.cjs). +- Do NOT change `plan.cjs`, the index/learning modules, or the debate/consensus logic. + +--- + +### Task 1: CLI pure helpers (`engine/cliCommands.cjs`) + +**Files:** +- Create: `.claude/review/engine/cliCommands.cjs` +- Create: `.claude/review/engine/cliCommands.test.cjs` + +**Interfaces:** +- Produces: `setLearningStatus(learnings, id, status) -> learnings` (new object; throws if `id` absent); `listLearnings(learnings, statusFilter) -> rows[]`; `preflightSummary(plan, impact) -> string`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/cliCommands.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { setLearningStatus, listLearnings, preflightSummary } = require('./cliCommands.cjs'); + +const learnings = { version: 1, learnings: [ + { id: 'L-a', kind: 'suppress', status: 'proposed', support: 3, paths: ['src/**'], example: 'x' }, + { id: 'L-b', kind: 'rule', status: 'approved', support: 4, paths: ['pages/**'], ruleText: 'y' }, +] }; + +test('setLearningStatus flips target, preserves others, no mutation', () => { + const updated = setLearningStatus(learnings, 'L-a', 'approved'); + assert.equal(updated.learnings.find((l) => l.id === 'L-a').status, 'approved'); + assert.equal(updated.learnings.find((l) => l.id === 'L-b').status, 'approved'); + assert.equal(learnings.learnings[0].status, 'proposed'); // original untouched +}); + +test('setLearningStatus throws on unknown id', () => { + assert.throws(() => setLearningStatus(learnings, 'nope', 'approved'), /not found/); +}); + +test('listLearnings filters by status', () => { + assert.deepEqual(listLearnings(learnings, 'approved').map((r) => r.id), ['L-b']); + assert.equal(listLearnings(learnings).length, 2); +}); + +test('preflightSummary includes risk, reviewer, agents, blast radius', () => { + const plan = { profile: 'standard', risk: { score: 46, level: 'CRITICAL', reviewer: 'Caleb Cox', special: [] }, agents: [{ id: 'financial', matchedBy: 'path:src/components/HrTools/**' }] }; + const impact = { blastRadius: 166, truncated: false, topImpacted: [{ file: 'src/x.tsx', dependentCount: 29 }] }; + const s = preflightSummary(plan, impact); + assert.match(s, /CRITICAL/); + assert.match(s, /Caleb Cox/); + assert.match(s, /financial/); + assert.match(s, /blastRadius 166/); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./cliCommands.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/cliCommands.cjs`: +```js +'use strict'; + +function setLearningStatus(learnings, id, status) { + const list = (learnings && learnings.learnings) || []; + if (!list.some((l) => l.id === id)) throw new Error(`Learning not found: ${id}`); + return { ...learnings, learnings: list.map((l) => (l.id === id ? { ...l, status } : l)) }; +} + +function listLearnings(learnings, statusFilter) { + const list = (learnings && learnings.learnings) || []; + return list + .filter((l) => !statusFilter || l.status === statusFilter) + .map((l) => ({ id: l.id, kind: l.kind, status: l.status, support: l.support, paths: l.paths || [], example: l.example || l.ruleText || '' })); +} + +function preflightSummary(plan, impact) { + const lines = []; + lines.push(`profile: ${plan.profile}`); + const r = plan.risk; + lines.push(`risk: ${r.score} ${r.level} (reviewer: ${r.reviewer})`); + if (r.special && r.special.length) lines.push(`special: ${r.special.join(', ')}`); + lines.push('agents:'); + for (const a of plan.agents) lines.push(` - ${a.id} [${a.matchedBy}]`); + if (impact) { + lines.push(`impact: blastRadius ${impact.blastRadius}${impact.truncated ? ' (truncated)' : ''}`); + for (const t of (impact.topImpacted || []).filter((x) => x.dependentCount > 0).slice(0, 5)) { + lines.push(` - ${t.dependentCount} dependents: ${t.file}`); + } + } + return lines.join('\n'); +} + +module.exports = { setLearningStatus, listLearnings, preflightSummary }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/cliCommands.cjs .claude/review/engine/cliCommands.test.cjs +git -C commit --no-verify -m "feat(review): pure CLI helpers (status, list, preflight)" +``` + +--- + +### Task 2: CLI dispatcher (`cli.cjs`) — all commands except `run` + +**Files:** +- Create: `.claude/review/cli.cjs` +- Create: `.claude/review/engine/cli.test.cjs` +- Modify: `package.json` (add `review` script) + +**Interfaces:** +- Consumes: engine modules + `cliCommands` (Task 1). +- Produces: `main(argv) -> exitCode` (0 ok, 1 error/unknown); the `yarn review` script. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/cli.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { main } = require('../cli.cjs'); + +function run(args) { + const orig = process.stdout.write.bind(process.stdout); + let s = ''; + process.stdout.write = (x) => { s += x; return true; }; + let code; + try { code = main(args); } finally { process.stdout.write = orig; } + return { code, s }; +} + +test('help returns 0 and prints usage', () => { + const { code, s } = run(['help']); + assert.equal(code, 0); + assert.match(s, /usage: yarn review/); +}); + +test('no command prints usage', () => { + const { code, s } = run([]); + assert.equal(code, 0); + assert.match(s, /usage: yarn review/); +}); + +test('unknown command returns 1', () => { + const { code, s } = run(['definitely-not-a-command']); + assert.equal(code, 1); + assert.match(s, /unknown command/); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`../cli.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/cli.cjs`: +```js +'use strict'; +const { join } = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { readFileSync } = require('node:fs'); +const { loadConfig } = require('./engine/loadConfig.cjs'); +const { buildPlan } = require('./engine/plan.cjs'); +const { loadOrBuildIndex, gitHead, listRepoFiles } = require('./engine/indexStore.cjs'); +const { queryImpact } = require('./engine/queryImpact.cjs'); +const { mineLearnings } = require('./engine/mineLearnings.cjs'); +const { parsePending, appendFeedback, loadFeedback, loadLearnings, saveLearnings, mergeProposals } = require('./engine/learningsStore.cjs'); +const { setLearningStatus, listLearnings, preflightSummary } = require('./engine/cliCommands.cjs'); + +const ROOT = process.cwd(); +const RD = join(ROOT, '.claude/review'); +const CONFIG = join(RD, 'config.yml'); +const SCHEMA = join(RD, 'config.schema.json'); +const INDEX = join(RD, 'index'); +const FEEDBACK = join(RD, 'learnings/feedback.jsonl'); +const LEARNINGS = join(RD, 'learnings/learnings.yml'); + +function out(s) { process.stdout.write(s + '\n'); } +function flag(argv, name) { const i = argv.indexOf(name); return i >= 0 ? argv[i + 1] : undefined; } + +function changedFiles(base) { + let b = base; + if (!b) { + try { b = execFileSync('git', ['-C', ROOT, 'merge-base', 'main', 'HEAD'], { encoding: 'utf8' }).trim(); } + catch { b = 'HEAD~1'; } + } + const files = execFileSync('git', ['-C', ROOT, 'diff', '--name-only', `${b}...HEAD`], { encoding: 'utf8' }) + .split('\n').map((s) => s.trim()).filter(Boolean); + return { base: b, files }; +} + +function loadIndex() { + return loadOrBuildIndex({ repoRoot: ROOT, indexPath: INDEX, head: gitHead(ROOT), files: listRepoFiles(ROOT) }); +} + +const USAGE = `usage: yarn review + config show|validate show or validate the review config + index rebuild the import-graph cache + impact [--base ] cross-file blast radius for the current diff + feedback ingest marked outcomes + learn [--min-support N] mine feedback into proposed learnings + learnings [--status S] list learnings + approve | reject set a learning's status + run [--base ] [mode] pre-flight + launch the Claude Code review + help`; + +function main(argv) { + const cmd = argv[0]; + const rest = argv.slice(1); + switch (cmd) { + case 'config': { + const cfg = loadConfig({ configPath: CONFIG, schemaPath: SCHEMA }); + out(rest[0] === 'validate' ? 'config OK' : JSON.stringify(cfg, null, 2)); + return 0; + } + case 'index': { + const g = loadIndex(); + out(`Indexed ${g.fileCount} files; ${Object.keys(g.importedBy).length} have dependents.`); + return 0; + } + case 'impact': { + const { files } = changedFiles(flag(rest, '--base')); + out(JSON.stringify(queryImpact(files, loadIndex(), {}), null, 2)); + return 0; + } + case 'feedback': { + if (!rest[0]) { out('usage: yarn review feedback '); return 1; } + const entries = parsePending(readFileSync(rest[0], 'utf8')).map((e) => ({ ts: new Date().toISOString(), ...e })); + appendFeedback(FEEDBACK, entries); + out(`Ingested ${entries.length} outcomes`); + return 0; + } + case 'learn': { + const minSupport = flag(rest, '--min-support') ? Number(flag(rest, '--min-support')) : 3; + const proposals = mineLearnings(loadFeedback(FEEDBACK), { minSupport }); + const merged = mergeProposals(loadLearnings(LEARNINGS), proposals); + saveLearnings(LEARNINGS, merged); + out(`Mined ${proposals.length} proposals; ${merged.learnings.length} total`); + return 0; + } + case 'learnings': { + out(JSON.stringify(listLearnings(loadLearnings(LEARNINGS), flag(rest, '--status')), null, 2)); + return 0; + } + case 'approve': + case 'reject': { + if (!rest[0]) { out(`usage: yarn review ${cmd} `); return 1; } + const status = cmd === 'approve' ? 'approved' : 'rejected'; + saveLearnings(LEARNINGS, setLearningStatus(loadLearnings(LEARNINGS), rest[0], status)); + out(`${rest[0]} -> ${status}`); + return 0; + } + case 'help': + case undefined: + out(USAGE); + return 0; + default: + out(`unknown command: ${cmd}\n\n${USAGE}`); + return 1; + } +} + +if (require.main === module) { + try { process.exit(main(process.argv.slice(2))); } + catch (e) { process.stderr.write(`error: ${e.message}\n`); process.exit(1); } +} + +module.exports = { main }; +``` +(`buildPlan` and `preflightSummary` are imported now but used by the `run` command added in Task 3 — harmless until then.) + +Add to `package.json` `scripts`: +```json +"review": "yarn node .claude/review/cli.cjs" +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS (`cli.test.cjs` green). +Then smoke the real CLI: +```bash +yarn --cwd review help +yarn --cwd review config validate # -> "config OK" +yarn --cwd review learnings # -> "[]" (seed) +yarn --cwd review nope; echo "exit=$?" # -> usage + exit=1 +``` +Expected: help/usage prints; `config validate` prints `config OK`; `learnings` prints `[]`; unknown command exits 1. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/cli.cjs .claude/review/engine/cli.test.cjs package.json +git -C commit --no-verify -m "feat(review): unified review CLI dispatcher (config/index/impact/feedback/learn/learnings/approve/reject)" +``` + +--- + +### Task 3: `review run` (pre-flight + `claude -p`) + +**Files:** +- Modify: `.claude/review/cli.cjs` (add the `run` case) + +**Interfaces:** +- Consumes: `buildPlan`, `queryImpact`, `loadConfig`, `preflightSummary` (already imported in Task 2). + +- [ ] **Step 1: Add the `run` case to `main`'s switch (immediately before `case 'help':`)** + +Insert into `.claude/review/cli.cjs`: +```js + case 'run': { + const { writeFileSync } = require('node:fs'); + const os = require('node:os'); + const base = flag(rest, '--base'); + const mode = rest.find((a) => !a.startsWith('--') && a !== base) || 'standard'; + const { base: b, files } = changedFiles(base); + const diff = execFileSync('git', ['-C', ROOT, 'diff', `${b}...HEAD`], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + const stat = execFileSync('git', ['-C', ROOT, 'diff', '--stat', `${b}...HEAD`], { encoding: 'utf8' }); + const ins = stat.match(/(\d+) insertions?\(\+\)/); + const del = stat.match(/(\d+) deletions?\(-\)/); + const linesChanged = (ins ? Number(ins[1]) : 0) + (del ? Number(del[1]) : 0); + const cfg = loadConfig({ configPath: CONFIG, schemaPath: SCHEMA }); + const plan = buildPlan({ files, diffText: diff, linesChanged, scope: 'multi_feature' }, cfg); + let impact = null; + if (cfg.index && cfg.index.enabled) impact = queryImpact(files, loadIndex(), {}); + out(preflightSummary(plan, impact)); + writeFileSync(join(os.tmpdir(), 'review_plan.json'), JSON.stringify({ ...plan, impact }, null, 2)); + if (rest.includes('--no-launch')) { + out(`\nwould run: claude -p "/agent-review ${mode}"`); + return 0; + } + out(`\nlaunching: claude -p "/agent-review ${mode}" ...\n`); + try { + execFileSync('claude', ['-p', `/agent-review ${mode}`], { stdio: 'inherit' }); + } catch (e) { + out(`(could not launch claude automatically: ${e.message})`); + out(`Run it manually in Claude Code: /agent-review ${mode}`); + } + return 0; + } +``` + +- [ ] **Step 2: Verify the `claude -p` slash-command invocation** + +Confirm how Claude Code's print mode triggers a slash command: +```bash +claude -p "/agent-review standard" --help >/dev/null 2>&1 || true +# Quick probe (do NOT run a full review): check the CLI accepts the slash form. +claude --help 2>&1 | grep -iE "print|-p," | head -3 +``` +If a bare `/agent-review` is NOT honored in `-p` mode, change the invocation in the `run` case to a +natural-language prompt that triggers it: +```js + execFileSync('claude', ['-p', `Run the /agent-review ${mode} command on the current branch`], { stdio: 'inherit' }); +``` +(Pick whichever form actually triggers the command; keep the `--no-launch` and catch fallbacks.) + +- [ ] **Step 3: Smoke the pre-flight without launching** + +```bash +yarn --cwd review run --no-launch +``` +Expected: prints the pre-flight (profile, risk score/level/reviewer, selected agents with match +reasons, impact blast radius for the current branch's diff), then `would run: claude -p "/agent-review standard"`. (The engine suite must still be green: `yarn --cwd test:review`.) + +- [ ] **Step 4: Commit** + +```bash +git -C add .claude/review/cli.cjs +git -C commit --no-verify -m "feat(review): review run pre-flight + claude -p launch" +``` + +--- + +### Task 4: Final verification + +**Files:** none (verification only). + +- [ ] **Step 1: Full suite green** + +Run: `yarn --cwd test:review` +Expected: PASS — all engine tests (Phases A–D), including `cliCommands.test.cjs` and `cli.test.cjs`. + +- [ ] **Step 2: Exercise every command (smoke)** + +```bash +WT= +yarn --cwd "$WT" review help +yarn --cwd "$WT" review config validate +yarn --cwd "$WT" review index +yarn --cwd "$WT" review impact +yarn --cwd "$WT" review learnings +yarn --cwd "$WT" review run --no-launch +yarn --cwd "$WT" review bogus; echo "unknown exit=$?" +``` +Expected: each prints sensible output; `config validate` → `config OK`; `impact`/`run` print +real risk/blast-radius for the branch; unknown command exits 1. + +- [ ] **Step 3: approve/reject round-trip (no committed-file corruption)** + +```bash +WT= +# seed a temporary proposed learning, flip it, then restore the committed seed +node -e "const {saveLearnings}=require('$WT/.claude/review/engine/learningsStore.cjs'); saveLearnings('$WT/.claude/review/learnings/learnings.yml',{version:1,learnings:[{id:'L-x',kind:'suppress',status:'proposed',support:3,paths:['src/**'],example:'e'}]});" 2>/dev/null || yarn --cwd "$WT" node -e "const {saveLearnings}=require('./.claude/review/engine/learningsStore.cjs'); saveLearnings('./.claude/review/learnings/learnings.yml',{version:1,learnings:[{id:'L-x',kind:'suppress',status:'proposed',support:3,paths:['src/**'],example:'e'}]});" +yarn --cwd "$WT" review approve L-x # -> "L-x -> approved" +yarn --cwd "$WT" review learnings --status approved # -> shows L-x +git -C "$WT" checkout -- .claude/review/learnings/learnings.yml # restore committed seed +``` +Expected: `approve` flips status; `learnings --status approved` lists `L-x`; the committed seed is +restored clean afterward. + +- [ ] **Step 4: lint:ts unaffected + tree clean** + +```bash +yarn --cwd lint:ts # no NEW errors referencing .claude/review (pre-existing codegen errors OK) +git -C status --short # clean (seed restored) +git -C log --oneline -6 +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- Single `review` CLI + `yarn review` script → Task 2 ✓ +- `config show/validate` (exit 1 on invalid via top-level catch) → Task 2 ✓ +- `index`, `impact`, `feedback`, `learn` → Task 2 ✓ +- `learnings`, `approve`, `reject` → Task 2 (+ helpers Task 1) ✓ +- `run` pre-flight + `claude -p` (with verification + fallback) → Task 3 ✓ +- New pure logic tested (`setLearningStatus`/`listLearnings`/`preflightSummary`) → Task 1 ✓ +- No new review logic / engine unchanged → respected (CLI only orchestrates) ✓ +- Acceptance criteria 1–7 → Tasks 1–4 ✓ + +**2. Placeholder scan:** No TBD/TODO; complete code in each step. Task 3 Step 2 is a real verification with an explicit alternative invocation (not a placeholder). `--no-launch` provides a safe, testable path that avoids kicking off a costly full review during the build. + +**3. Type consistency:** `main(argv) -> number` defined Task 2, tested Task 1's sibling `cli.test.cjs` (Task 2). `setLearningStatus`/`listLearnings`/`preflightSummary` signatures defined Task 1 and called identically in `cli.cjs` (Tasks 2/3). `buildPlan({files,diffText,linesChanged,scope}, cfg)` and `queryImpact(files, graph, {})` match the Phase A/B exports. `loadOrBuildIndex({repoRoot,indexPath,head,files})` matches Phase B. `preflightSummary(plan, impact)` consumes `plan.risk.{score,level,reviewer,special}`, `plan.agents[].{id,matchedBy}`, `impact.{blastRadius,truncated,topImpacted}` — all produced by `buildPlan`/`queryImpact`. + +--- + +## Notes for the executor + +- No new dependencies; CommonJS; `yarn test:review` only (never `node --test`); lowercase `.claude/`; commits `--no-verify`. +- Never let `review run` (without `--no-launch`) kick off a full review during testing — always smoke with `--no-launch`. +- Restore `learnings.yml`/`feedback.jsonl` committed seeds after any smoke that writes them. diff --git a/docs/superpowers/plans/2026-06-23-agent-review-index-layer.md b/docs/superpowers/plans/2026-06-23-agent-review-index-layer.md new file mode 100644 index 0000000000..ac3ce5c6d8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-agent-review-index-layer.md @@ -0,0 +1,700 @@ +# Agent-Review Index Layer (Gap 2) Implementation Plan — CommonJS / Yarn PnP + +> **For agentic workers:** Implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Add a persisted, file-level import graph of the codebase plus a query for transitive dependents ("this change affects these N callers"), wired into the `agent-review` command's Stage 1B — replacing today's grep-based dependency analysis. + +**Architecture:** Pure CommonJS modules under `.claude/review/engine/` — `resolveImport` (specifier → repo path), `buildGraph` (import adjacency), `queryImpact` (transitive dependents + blast radius) — plus a thin fs/git glue module `indexStore` (HEAD-keyed gitignored cache) and an `impact.cjs` CLI. No embeddings, no TS type-checker. + +**Tech Stack:** Node CommonJS `.cjs`, `node:test` via the existing single-process runner, no new dependencies. **Yarn 4 + PnP.** Builds on the Phase A engine. + +## Global Constraints — READ FIRST (platform-specific) + +- **Yarn 4 + PnP, NO `node_modules`.** Run engine via `yarn node`; test via `yarn --cwd test:review` (the single-process runner). **NEVER `node --test`.** +- **CommonJS `.cjs`** only (`require`/`module.exports`), NOT ESM. +- **No new dependencies** — only Node built-ins (`node:fs`, `node:path`, `node:child_process`, `node:os`). +- **Lowercase `.claude/`** paths. Commit with `git -C commit --no-verify`. +- Work ONLY in the worktree at `` (absolute paths for Write/Read/Edit, `git -C`/`yarn --cwd`). Do NOT `cd`. NEVER touch the main checkout. +- The Phase A engine already exists at `.claude/review/engine/` with the runner `run-tests.cjs` that auto-includes every `*.test.cjs`. New test files are picked up automatically. +- `path.posix` for all path math (repo paths are posix-style from `git ls-files`). + +--- + +### Task 1: Import resolver (`resolveImport.cjs`) + +**Files:** +- Create: `.claude/review/engine/resolveImport.cjs` +- Create: `.claude/review/engine/resolveImport.test.cjs` + +**Interfaces:** +- Produces: `resolveImport(fromFile, spec, fileSet) -> string | null` — maps an import specifier to a repo-relative file in `fileSet`, or `null` if external/unresolvable. Also exports `candidates(base) -> string[]` and `EXTS`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/resolveImport.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveImport } = require('./resolveImport.cjs'); + +const fileSet = new Set([ + 'src/b.ts', + 'src/a/c.ts', + 'src/lib/index.ts', + 'src/d.tsx', + 'pages/x.page.tsx', +]); + +test('resolves relative import with extension inference', () => { + assert.equal(resolveImport('src/a/c.ts', '../b', fileSet), 'src/b.ts'); +}); + +test('resolves alias import (src/*)', () => { + assert.equal(resolveImport('src/a/c.ts', 'src/d', fileSet), 'src/d.tsx'); +}); + +test('resolves directory import to index file', () => { + assert.equal(resolveImport('src/a/c.ts', 'src/lib', fileSet), 'src/lib/index.ts'); +}); + +test('returns null for bare/external specifiers', () => { + assert.equal(resolveImport('src/a/c.ts', 'react', fileSet), null); + assert.equal(resolveImport('src/a/c.ts', '@mui/material', fileSet), null); +}); + +test('returns null for unresolvable relative import', () => { + assert.equal(resolveImport('src/a/c.ts', './nope', fileSet), null); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./resolveImport.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/resolveImport.cjs`: +```js +'use strict'; +const path = require('node:path'); + +const ALIASES = ['src/', 'pages/', '__tests__/']; +const EXTS = ['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.json']; + +function candidates(base) { + const out = [base]; + for (const e of EXTS) out.push(base + e); + for (const e of EXTS) out.push(base + '/index' + e); + return out; +} + +function resolveImport(fromFile, spec, fileSet) { + let base; + if (ALIASES.some((a) => spec === a.slice(0, -1) || spec.startsWith(a))) { + base = spec; // already repo-root-relative (src/..., pages/..., __tests__/...) + } else if (spec.startsWith('.')) { + base = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec)); + } else { + return null; // bare / external + } + for (const c of candidates(base)) { + if (fileSet.has(c)) return c; + } + return null; +} + +module.exports = { resolveImport, candidates, EXTS }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/resolveImport.cjs .claude/review/engine/resolveImport.test.cjs +git -C commit --no-verify -m "feat(review): import specifier resolver for index graph" +``` + +--- + +### Task 2: Graph builder (`buildGraph.cjs`) + +**Files:** +- Create: `.claude/review/engine/buildGraph.cjs` +- Create: `.claude/review/engine/buildGraph.test.cjs` + +**Interfaces:** +- Consumes: `resolveImport` (Task 1). +- Produces: `buildGraph(files, readFile, fileSet) -> { imports, importedBy }` where `readFile(file) -> string` is injected; both maps are `{ [file]: string[] }`. Also exports `extractSpecifiers(text) -> string[]`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/buildGraph.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { buildGraph, extractSpecifiers } = require('./buildGraph.cjs'); + +test('extractSpecifiers finds import/export/require/dynamic specifiers', () => { + const text = ` + import a from './a'; + import { b } from 'src/b'; + export { c } from './c'; + const d = require('./d'); + const e = await import('./e'); + import 'side-effect'; + `; + const specs = extractSpecifiers(text).sort(); + assert.deepEqual(specs, ['./a', './c', './d', './e', 'side-effect', 'src/b'].sort()); +}); + +test('buildGraph builds imports + importedBy, drops externals, dedupes', () => { + const files = ['src/a.tsx', 'src/b.ts', 'src/c.ts']; + const fileSet = new Set(files); + const contents = { + 'src/a.tsx': "import { b } from 'src/b';\nimport x from 'react';\nimport { b2 } from './b';", + 'src/b.ts': "import { c } from './c';", + 'src/c.ts': "export const c = 1;", + }; + const graph = buildGraph(files, (f) => contents[f], fileSet); + assert.deepEqual(graph.imports['src/a.tsx'], ['src/b.ts']); // react dropped, dup b deduped + assert.deepEqual(graph.imports['src/b.ts'], ['src/c.ts']); + assert.deepEqual(graph.imports['src/c.ts'], []); + assert.deepEqual(graph.importedBy['src/b.ts'], ['src/a.tsx']); + assert.deepEqual(graph.importedBy['src/c.ts'], ['src/b.ts']); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./buildGraph.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/buildGraph.cjs`: +```js +'use strict'; +const { resolveImport } = require('./resolveImport.cjs'); + +const PATTERNS = [ + /\bimport\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g, + /\bimport\s*['"]([^'"]+)['"]/g, + /\bexport\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g, + /\brequire\(\s*['"]([^'"]+)['"]\s*\)/g, + /\bimport\(\s*['"]([^'"]+)['"]\s*\)/g, +]; + +function extractSpecifiers(text) { + const specs = new Set(); + for (const re of PATTERNS) { + re.lastIndex = 0; + let m; + while ((m = re.exec(text)) !== null) specs.add(m[1]); + } + return [...specs]; +} + +function buildGraph(files, readFile, fileSet) { + const imports = {}; + const importedBy = {}; + for (const file of files) { + let text; + try { + text = readFile(file); + } catch { + text = ''; + } + const targets = new Set(); + for (const spec of extractSpecifiers(text)) { + const resolved = resolveImport(file, spec, fileSet); + if (resolved && resolved !== file) targets.add(resolved); + } + imports[file] = [...targets]; + for (const t of targets) { + (importedBy[t] = importedBy[t] || []).push(file); + } + } + return { imports, importedBy }; +} + +module.exports = { buildGraph, extractSpecifiers }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/buildGraph.cjs .claude/review/engine/buildGraph.test.cjs +git -C commit --no-verify -m "feat(review): file-level import graph builder" +``` + +--- + +### Task 3: Impact query (`queryImpact.cjs`) + +**Files:** +- Create: `.claude/review/engine/queryImpact.cjs` +- Create: `.claude/review/engine/queryImpact.test.cjs` + +**Interfaces:** +- Produces: `queryImpact(changedFiles, graph, { maxDepth = 3, maxNodes = 200 }) -> { directDependents, transitiveDependents, blastRadius, topImpacted, truncated }`. `graph` is the `{ imports, importedBy }` shape from Task 2. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/queryImpact.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { queryImpact } = require('./queryImpact.cjs'); + +// chain: a <- b <- c (importedBy[a] = [b], importedBy[b] = [c]) +const graph = { + imports: {}, + importedBy: { 'a.ts': ['b.ts'], 'b.ts': ['c.ts'], 'shared.ts': ['a.ts', 'b.ts'] }, +}; + +test('direct + transitive dependents and blast radius', () => { + const r = queryImpact(['a.ts'], graph, {}); + assert.deepEqual(r.directDependents['a.ts'], ['b.ts']); + assert.deepEqual(r.transitiveDependents.sort(), ['b.ts', 'c.ts']); + assert.equal(r.blastRadius, 2); + assert.equal(r.topImpacted[0].file, 'a.ts'); + assert.equal(r.topImpacted[0].dependentCount, 1); + assert.equal(r.truncated, false); +}); + +test('maxDepth limits traversal', () => { + const r = queryImpact(['a.ts'], graph, { maxDepth: 1 }); + assert.deepEqual(r.transitiveDependents, ['b.ts']); + assert.equal(r.blastRadius, 1); +}); + +test('maxNodes cap sets truncated', () => { + const r = queryImpact(['a.ts'], graph, { maxNodes: 1 }); + assert.equal(r.truncated, true); + assert.equal(r.transitiveDependents.length, 1); +}); + +test('changed files are excluded from their own dependents', () => { + const r = queryImpact(['shared.ts', 'a.ts'], graph, {}); + assert.ok(!r.directDependents['shared.ts'].includes('a.ts')); // a.ts is itself changed + assert.deepEqual(r.directDependents['shared.ts'], ['b.ts']); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./queryImpact.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/queryImpact.cjs`: +```js +'use strict'; + +function queryImpact(changedFiles, graph, opts = {}) { + const maxDepth = opts.maxDepth ?? 3; + const maxNodes = opts.maxNodes ?? 200; + const importedBy = graph.importedBy || {}; + const changedSet = new Set(changedFiles); + + const directDependents = {}; + for (const f of changedFiles) { + directDependents[f] = (importedBy[f] || []).filter((d) => !changedSet.has(d)); + } + + const visited = new Set(); + let truncated = false; + let frontier = [...changedFiles]; + for (let depth = 0; depth < maxDepth && frontier.length; depth++) { + const next = []; + for (const f of frontier) { + for (const dep of importedBy[f] || []) { + if (changedSet.has(dep) || visited.has(dep)) continue; + if (visited.size >= maxNodes) { + truncated = true; + break; + } + visited.add(dep); + next.push(dep); + } + if (truncated) break; + } + if (truncated) break; + frontier = next; + } + + const transitiveDependents = [...visited]; + const topImpacted = changedFiles + .map((f) => ({ file: f, dependentCount: directDependents[f].length })) + .sort((a, b) => b.dependentCount - a.dependentCount); + + return { directDependents, transitiveDependents, blastRadius: transitiveDependents.length, topImpacted, truncated }; +} + +module.exports = { queryImpact }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/queryImpact.cjs .claude/review/engine/queryImpact.test.cjs +git -C commit --no-verify -m "feat(review): transitive impact query over import graph" +``` + +--- + +### Task 4: Index store + cache (`indexStore.cjs`) + +**Files:** +- Create: `.claude/review/engine/indexStore.cjs` +- Create: `.claude/review/engine/indexStore.test.cjs` + +**Interfaces:** +- Consumes: `buildGraph` (Task 2). +- Produces: `loadOrBuildIndex({ repoRoot, indexPath, head, files }) -> graph` (graph = `{ version, head, fileCount, imports, importedBy }`); writes/reads `indexPath/graph.json`; reuses cache when `cached.head === head`. Also exports `gitHead(repoRoot)`, `listRepoFiles(repoRoot)`. Has a `--build` CLI guard for the `review:index` script. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/indexStore.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } = require('node:fs'); +const { join } = require('node:path'); +const os = require('node:os'); +const { loadOrBuildIndex } = require('./indexStore.cjs'); + +function tmpRepo() { + const root = mkdtempSync(join(os.tmpdir(), 'idxtest-')); + mkdirSync(join(root, 'src'), { recursive: true }); + writeFileSync(join(root, 'src/a.ts'), "import { b } from './b';"); + writeFileSync(join(root, 'src/b.ts'), 'export const b = 1;'); + return root; +} + +test('builds graph and writes graph.json', () => { + const root = tmpRepo(); + const indexPath = join(root, '.claude/review/index'); + const graph = loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h1', files: ['src/a.ts', 'src/b.ts'] }); + assert.equal(graph.head, 'h1'); + assert.equal(graph.fileCount, 2); + assert.deepEqual(graph.importedBy['src/b.ts'], ['src/a.ts']); + rmSync(root, { recursive: true, force: true }); +}); + +test('reuses cache when head matches (no rebuild)', () => { + const root = tmpRepo(); + const indexPath = join(root, '.claude/review/index'); + loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h1', files: ['src/a.ts', 'src/b.ts'] }); + // tamper the cache with a sentinel; a reuse returns it unchanged, a rebuild drops it + const gf = join(indexPath, 'graph.json'); + const cached = JSON.parse(readFileSync(gf, 'utf8')); + cached.sentinel = 'KEEP'; + writeFileSync(gf, JSON.stringify(cached)); + const again = loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h1', files: ['src/a.ts', 'src/b.ts'] }); + assert.equal(again.sentinel, 'KEEP'); + rmSync(root, { recursive: true, force: true }); +}); + +test('rebuilds when head differs', () => { + const root = tmpRepo(); + const indexPath = join(root, '.claude/review/index'); + loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h1', files: ['src/a.ts', 'src/b.ts'] }); + const gf = join(indexPath, 'graph.json'); + const cached = JSON.parse(readFileSync(gf, 'utf8')); + cached.sentinel = 'KEEP'; + writeFileSync(gf, JSON.stringify(cached)); + const rebuilt = loadOrBuildIndex({ repoRoot: root, indexPath, head: 'h2', files: ['src/a.ts', 'src/b.ts'] }); + assert.equal(rebuilt.sentinel, undefined); + assert.equal(rebuilt.head, 'h2'); + rmSync(root, { recursive: true, force: true }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./indexStore.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/indexStore.cjs`: +```js +'use strict'; +const { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } = require('node:fs'); +const { join } = require('node:path'); +const { execFileSync } = require('node:child_process'); +const { buildGraph } = require('./buildGraph.cjs'); + +const INDEX_RE = /^(src|pages|__tests__)\/.*\.(ts|tsx|js|jsx)$/; + +function gitHead(repoRoot) { + return execFileSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); +} + +function listRepoFiles(repoRoot) { + const out = execFileSync('git', ['-C', repoRoot, 'ls-files'], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + return out.split('\n').map((s) => s.trim()).filter((f) => INDEX_RE.test(f)); +} + +function loadOrBuildIndex({ repoRoot, indexPath, head, files }) { + const graphFile = join(indexPath, 'graph.json'); + if (existsSync(graphFile)) { + try { + const cached = JSON.parse(readFileSync(graphFile, 'utf8')); + if (cached.head === head) return cached; + } catch { + /* fall through to rebuild */ + } + } + const fileSet = new Set(files); + const { imports, importedBy } = buildGraph( + files, + (f) => readFileSync(join(repoRoot, f), 'utf8'), + fileSet, + ); + const graph = { version: 1, head, fileCount: files.length, imports, importedBy }; + mkdirSync(indexPath, { recursive: true }); + writeFileSync(graphFile, JSON.stringify(graph)); + return graph; +} + +if (require.main === module) { + const repoRoot = process.cwd(); + const indexPath = join(repoRoot, '.claude/review/index'); + if (process.argv.includes('--build')) { + const gf = join(indexPath, 'graph.json'); + if (existsSync(gf)) rmSync(gf); + } + const graph = loadOrBuildIndex({ + repoRoot, + indexPath, + head: gitHead(repoRoot), + files: listRepoFiles(repoRoot), + }); + const withDeps = Object.keys(graph.importedBy).length; + process.stdout.write(`Indexed ${graph.fileCount} files; ${withDeps} have dependents. head=${graph.head}\n`); +} + +module.exports = { loadOrBuildIndex, gitHead, listRepoFiles }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/indexStore.cjs .claude/review/engine/indexStore.test.cjs +git -C commit --no-verify -m "feat(review): HEAD-keyed import-graph cache (indexStore)" +``` + +--- + +### Task 5: Impact CLI (`impact.cjs`) + +**Files:** +- Create: `.claude/review/engine/impact.cjs` +- Create: `.claude/review/engine/impact.test.cjs` + +**Interfaces:** +- Consumes: `loadOrBuildIndex`, `gitHead`, `listRepoFiles` (Task 4); `queryImpact` (Task 3). +- Produces: CLI `yarn node .claude/review/engine/impact.cjs --root --index --changed [--max-depth N] [--max-nodes N]` → impact report JSON on stdout. Exports `parseArgs(argv) -> object`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/impact.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { parseArgs } = require('./impact.cjs'); + +test('parseArgs reads --flag value pairs', () => { + const a = parseArgs(['--root', '/r', '--changed', '/c.txt', '--max-depth', '2']); + assert.equal(a.root, '/r'); + assert.equal(a.changed, '/c.txt'); + assert.equal(a['max-depth'], '2'); +}); + +test('parseArgs ignores non-flag tokens', () => { + const a = parseArgs(['junk', '--root', '/r']); + assert.equal(a.root, '/r'); + assert.equal(a.junk, undefined); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./impact.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/impact.cjs`: +```js +'use strict'; +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { loadOrBuildIndex, gitHead, listRepoFiles } = require('./indexStore.cjs'); +const { queryImpact } = require('./queryImpact.cjs'); + +function parseArgs(argv) { + const a = {}; + for (let i = 0; i < argv.length; i++) { + if (argv[i].startsWith('--')) { + a[argv[i].slice(2)] = argv[i + 1]; + i++; + } + } + return a; +} + +if (require.main === module) { + const a = parseArgs(process.argv.slice(2)); + const repoRoot = a.root || process.cwd(); + const indexPath = a.index || join(repoRoot, '.claude/review/index'); + const graph = loadOrBuildIndex({ + repoRoot, + indexPath, + head: gitHead(repoRoot), + files: listRepoFiles(repoRoot), + }); + const changed = readFileSync(a.changed, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean); + const opts = { + maxDepth: a['max-depth'] ? Number(a['max-depth']) : 3, + maxNodes: a['max-nodes'] ? Number(a['max-nodes']) : 200, + }; + process.stdout.write(JSON.stringify(queryImpact(changed, graph, opts), null, 2) + '\n'); +} + +module.exports = { parseArgs }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/impact.cjs .claude/review/engine/impact.test.cjs +git -C commit --no-verify -m "feat(review): impact CLI emitting dependents report" +``` + +--- + +### Task 6: Integration + final verification + +**Files:** +- Modify: `.gitignore` (worktree root) +- Modify: `.claude/review/config.yml` (flip `index.enabled`) +- Modify: `package.json` (add `review:index` script) +- Modify: `.claude/commands/agent-review.md` (Stage 1B only) + +**Interfaces:** Consumes `impact.cjs` (Task 5). + +- [ ] **Step 1: Gitignore the index cache** + +Append to the worktree-root `.gitignore` (create the line if absent): +``` +.claude/review/index/ +``` + +- [ ] **Step 2: Enable the index in config + add the rebuild script** + +In `.claude/review/config.yml`, change the `index` block to: +```yaml +index: { enabled: true, path: ".claude/review/index" } +``` +In `package.json` `scripts`, add: +```json +"review:index": "yarn node .claude/review/engine/indexStore.cjs --build" +``` + +- [ ] **Step 3: Wire impact into the command's Stage 1B** + +In `.claude/commands/agent-review.md`, replace the existing Stage 1B "Dependency Impact Analysis" +bash (the grep-based `for`-loop over changed files) with an engine call, gated on the index being +enabled (**`yarn node`**, not `node`): +```bash +REVIEW_DIR=".claude/review" +if grep -q "enabled: true" "$REVIEW_DIR/config.yml" 2>/dev/null; then + yarn node "$REVIEW_DIR/engine/impact.cjs" \ + --root "$(pwd)" \ + --index "$REVIEW_DIR/index" \ + --changed /tmp/changed_files.txt \ + > /tmp/review_impact.json + cat /tmp/review_impact.json +fi +``` +Then update the surrounding markdown so the command: (a) displays `blastRadius` and `topImpacted` +from `/tmp/review_impact.json`; (b) when launching the **Architecture** and **Data Integrity** +agents in Stage 1, includes the affected `directDependents`/`topImpacted` files in their prompts +with the instruction: "This change affects these dependent files — verify the change does not break +them." Leave `plan.cjs`, agent selection, and the debate/consensus stages unchanged. + +- [ ] **Step 4: Build the real index and smoke-test impact end-to-end** + +```bash +yarn --cwd review:index +git -C diff --name-only HEAD~1 > /tmp/changed_files.txt +yarn --cwd node .claude/review/engine/impact.cjs \ + --root --index /.claude/review/index --changed /tmp/changed_files.txt +``` +Expected: `review:index` prints "Indexed N files; M have dependents."; `impact.cjs` prints a valid +JSON report with `directDependents`, `transitiveDependents`, `blastRadius`, `topImpacted`, +`truncated`. Confirm `.claude/review/index/graph.json` exists and is NOT staged +(`git -C status --short` shows it ignored). + +- [ ] **Step 5: Full suite + lint, then commit** + +```bash +yarn --cwd test:review # all engine tests green (Phase A + index) +yarn --cwd lint:ts # no NEW engine-related errors (pre-existing codegen errors OK) +git -C add .gitignore .claude/review/config.yml package.json .claude/commands/agent-review.md +git -C commit --no-verify -m "feat(review): wire impact analysis into agent-review Stage 1B" +``` + +--- + +## Self-Review + +**1. Spec coverage:** +- Build file-level import graph (internal edges) → Tasks 1, 2 ✓ +- Direct + transitive dependents, blast radius, caps, top-impacted → Task 3 ✓ +- Gitignored HEAD-keyed cache, rebuild when stale/missing → Task 4 + Task 6 Step 1 ✓ +- `impact.cjs` CLI emitting report → Task 5 ✓ +- Integration: `index.enabled`, replace Stage 1B grep, feed dependents to Architecture + Data Integrity, `review:index` script → Task 6 ✓ +- `plan.cjs` + debate/consensus unchanged → respected throughout ✓ +- All pure/testable except `indexStore` glue; node:test via runner → every task ✓ +- Acceptance criteria 1–6 → Tasks 1–6 + Task 6 Steps 4–5 ✓ + +**2. Placeholder scan:** No TBD/TODO; every code step shows complete code. Task 6 Step 3 references the existing Stage 1B block to replace (faithful to the spec) and gives the exact replacement bash + prompt instruction — not a placeholder. + +**3. Type consistency:** `graph` shape `{ imports, importedBy }` (Tasks 2) is consumed by `queryImpact` (Task 3) and extended to `{ version, head, fileCount, ... }` by `loadOrBuildIndex` (Task 4); `queryImpact` only reads `importedBy`, so the extension is compatible. `impact.cjs` (Task 5) calls `loadOrBuildIndex({repoRoot, indexPath, head, files})` and `queryImpact(changed, graph, opts)` with the exact signatures defined. `resolveImport(fromFile, spec, fileSet)` (Task 1) is called identically in `buildGraph` (Task 2). + +--- + +## Notes for the executor + +- No new dependencies — Node built-ins only. +- **Never run `node --test`** under PnP — always `yarn test:review`. +- All paths lowercase `.claude/`; all commits `--no-verify`. +- The index cache (`graph.json`) must be gitignored and must NOT be committed. diff --git a/docs/superpowers/plans/2026-06-23-agent-review-learning-layer.md b/docs/superpowers/plans/2026-06-23-agent-review-learning-layer.md new file mode 100644 index 0000000000..7e1cb620b2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-23-agent-review-learning-layer.md @@ -0,0 +1,656 @@ +# Agent-Review Learning Layer (Gap 3) Implementation Plan — CommonJS / Yarn PnP + +> **For agentic workers:** Implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Add a feedback-learning loop — capture per-finding accepted/dismissed outcomes, mine recurring patterns into approval-gated learnings, and apply approved learnings (suppress noise, inject rules) to future reviews. + +**Architecture:** Pure CommonJS modules under `.claude/review/engine/` — `findingSignature` (stable finding signature), `mineLearnings` (feedback → proposals), `applyLearnings` (suppress + rule injection) — plus `learningsStore` (yaml/jsonl IO + a multi-mode CLI: emit/ingest/mine/rules/filter). File-based approval via `status:` in `learnings.yml`. No embeddings, no PR surface. + +**Tech Stack:** Node CommonJS `.cjs`, `node:test` via the existing runner, `yaml` (already a dep), `node:crypto` (built-in). **Yarn 4 + PnP.** Builds on Phases A + B. + +## Global Constraints — READ FIRST (platform-specific) + +- **Yarn 4 + PnP, NO `node_modules`.** Run engine via `yarn node`; test via `yarn --cwd test:review`. **NEVER `node --test`.** +- **CommonJS `.cjs`** only. **No new dependencies** (`yaml` already present; `node:crypto`/`fs`/`path` built-in). +- **Lowercase `.claude/`** paths. Commit with `git -C commit --no-verify`. +- Work ONLY in the worktree `` (absolute paths; `git -C`/`yarn --cwd`). Do NOT `cd`. NEVER touch the main checkout. +- The engine exists at `.claude/review/engine/` with `run-tests.cjs` auto-including every `*.test.cjs`. +- Current state to respect: `config.yml` has `learning.enabled: false` (multi-line block at lines ~139-144 with `path`, `approval_required: true`, `scope: local`); `engine/realConfig.test.cjs` asserts `cfg.learning.enabled === false` — Task 5 updates both together. The schema's `learning` block has `additionalProperties: false`, so `min_support` must be added to it before config can use it. +- `plan.cjs`, the index layer, and the debate/consensus *logic* are NOT modified. + +--- + +### Task 1: Finding signature (`findingSignature.cjs`) + +**Files:** +- Create: `.claude/review/engine/findingSignature.cjs` +- Create: `.claude/review/engine/findingSignature.test.cjs` + +**Interfaces:** +- Produces: `signature(finding) -> string` (12-char hex); `normalizeMessage(msg) -> string`; `topDir(file) -> string`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/findingSignature.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { signature, normalizeMessage, topDir } = require('./findingSignature.cjs'); + +test('normalizeMessage strips digits and quoted identifiers', () => { + assert.equal( + normalizeMessage("Missing id in 'ContactDetails' query line 42"), + normalizeMessage('Missing id in "TaskList" query line 99'), + ); +}); + +test('topDir returns first two path segments', () => { + assert.equal(topDir('src/components/Foo/Bar.tsx'), 'src/components'); + assert.equal(topDir('pages/api/x.ts'), 'pages/api'); +}); + +test('signature is equal for findings differing only by identifier/line', () => { + const a = { agent: 'data-integrity', category: 'graphql', file: 'src/components/Foo/A.tsx', message: "Missing id in 'ContactDetails' query at line 12" }; + const b = { agent: 'data-integrity', category: 'graphql', file: 'src/components/Foo/B.tsx', message: "Missing id in 'TaskList' query at line 88" }; + assert.equal(signature(a), signature(b)); +}); + +test('signature differs when agent differs', () => { + const a = { agent: 'data-integrity', category: 'graphql', file: 'src/x/A.tsx', message: 'same' }; + const b = { agent: 'security', category: 'graphql', file: 'src/x/A.tsx', message: 'same' }; + assert.notEqual(signature(a), signature(b)); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./findingSignature.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/findingSignature.cjs`: +```js +'use strict'; +const { createHash } = require('node:crypto'); + +function normalizeMessage(msg) { + return String(msg || '') + .toLowerCase() + .replace(/['"`][^'"`]*['"`]/g, ' ') // strip quoted identifiers + .replace(/\d+/g, ' ') // strip digits + .replace(/\s+/g, ' ') + .trim(); +} + +function topDir(file) { + const parts = String(file || '').split('/').filter(Boolean); + return parts.slice(0, 2).join('/'); +} + +function signature(finding) { + const key = [ + finding.agent || '', + finding.category || '', + normalizeMessage(finding.message), + topDir(finding.file), + ].join('|'); + return createHash('sha1').update(key).digest('hex').slice(0, 12); +} + +module.exports = { signature, normalizeMessage, topDir }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/findingSignature.cjs .claude/review/engine/findingSignature.test.cjs +git -C commit --no-verify -m "feat(review): stable finding signature for learning" +``` + +--- + +### Task 2: Mine learnings (`mineLearnings.cjs`) + +**Files:** +- Create: `.claude/review/engine/mineLearnings.cjs` +- Create: `.claude/review/engine/mineLearnings.test.cjs` + +**Interfaces:** +- Consumes: `topDir` (Task 1). +- Produces: `mineLearnings(feedbackEntries, { minSupport = 3 }) -> proposals[]`. Each proposal: `{ id, kind: 'suppress'|'rule', status: 'proposed', signature, agent, category, paths, support, rationale, example, ruleText? }`. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/mineLearnings.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { mineLearnings } = require('./mineLearnings.cjs'); + +function entry(sig, outcome, over = {}) { + return { signature: sig, outcome, agent: 'ux', category: 'i18n', file: 'src/components/Foo/A.tsx', message: 'hardcoded string', ...over }; +} + +test('proposes suppress for >=75% dismissed above support threshold', () => { + const fb = [entry('s1', 'dismissed'), entry('s1', 'dismissed'), entry('s1', 'dismissed'), entry('s1', 'accepted')]; + const p = mineLearnings(fb, { minSupport: 3 }); + assert.equal(p.length, 1); + assert.equal(p[0].kind, 'suppress'); + assert.equal(p[0].signature, 's1'); + assert.equal(p[0].id, 'L-s1'); + assert.deepEqual(p[0].paths, ['src/components/**']); +}); + +test('proposes rule for >=75% accepted', () => { + const fb = [entry('s2', 'accepted'), entry('s2', 'accepted'), entry('s2', 'accepted')]; + const p = mineLearnings(fb, { minSupport: 3 }); + assert.equal(p[0].kind, 'rule'); + assert.equal(typeof p[0].ruleText, 'string'); +}); + +test('no proposal below support threshold', () => { + const fb = [entry('s3', 'dismissed'), entry('s3', 'dismissed')]; + assert.deepEqual(mineLearnings(fb, { minSupport: 3 }), []); +}); + +test('no proposal for mixed outcomes', () => { + const fb = [entry('s4', 'dismissed'), entry('s4', 'accepted'), entry('s4', 'dismissed'), entry('s4', 'accepted')]; + assert.deepEqual(mineLearnings(fb, { minSupport: 3 }), []); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./mineLearnings.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/mineLearnings.cjs`: +```js +'use strict'; +const { topDir } = require('./findingSignature.cjs'); + +function mineLearnings(feedbackEntries, opts = {}) { + const minSupport = opts.minSupport ?? 3; + const groups = new Map(); + for (const e of feedbackEntries) { + if (!e.signature) continue; + if (!groups.has(e.signature)) groups.set(e.signature, []); + groups.get(e.signature).push(e); + } + const proposals = []; + for (const [sig, entries] of groups) { + const total = entries.length; + if (total < minSupport) continue; + const dismissed = entries.filter((e) => e.outcome === 'dismissed').length; + const accepted = entries.filter((e) => e.outcome === 'accepted').length; + const sample = entries[0]; + const base = { + id: `L-${sig}`, + signature: sig, + agent: sample.agent, + category: sample.category, + paths: [`${topDir(sample.file)}/**`], + support: total, + example: sample.message, + }; + if (dismissed / total >= 0.75) { + proposals.push({ ...base, kind: 'suppress', status: 'proposed', rationale: `Dismissed ${dismissed}/${total} times` }); + } else if (accepted / total >= 0.75) { + proposals.push({ ...base, kind: 'rule', status: 'proposed', ruleText: sample.message, rationale: `Accepted ${accepted}/${total} times` }); + } + } + return proposals; +} + +module.exports = { mineLearnings }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/mineLearnings.cjs .claude/review/engine/mineLearnings.test.cjs +git -C commit --no-verify -m "feat(review): mine feedback into proposed learnings" +``` + +--- + +### Task 3: Apply learnings (`applyLearnings.cjs`) + +**Files:** +- Create: `.claude/review/engine/applyLearnings.cjs` +- Create: `.claude/review/engine/applyLearnings.test.cjs` + +**Interfaces:** +- Produces: `filterFindings(findings, approved) -> { kept, suppressed }`; `rulesFromLearnings(approved) -> [{ paths, ruleText, agent }]`. `approved` is an array of learning objects (`kind`, `signature`, `paths`, `ruleText`/`example`, `agent`). + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/applyLearnings.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { filterFindings, rulesFromLearnings } = require('./applyLearnings.cjs'); + +const approved = [ + { kind: 'suppress', signature: 'sig-bad', paths: ['src/components/**'] }, + { kind: 'rule', agent: 'ux', paths: ['src/components/**'], ruleText: 'Always localize labels' }, +]; + +test('filterFindings suppresses matching signatures, keeps others', () => { + const findings = [ + { id: 'f1', signature: 'sig-bad', file: 'src/components/A.tsx' }, + { id: 'f2', signature: 'sig-ok', file: 'src/components/B.tsx' }, + ]; + const { kept, suppressed } = filterFindings(findings, approved); + assert.deepEqual(kept.map((f) => f.id), ['f2']); + assert.deepEqual(suppressed.map((f) => f.id), ['f1']); +}); + +test('rulesFromLearnings maps approved rule learnings', () => { + const rules = rulesFromLearnings(approved); + assert.equal(rules.length, 1); + assert.deepEqual(rules[0], { paths: ['src/components/**'], ruleText: 'Always localize labels', agent: 'ux' }); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./applyLearnings.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/applyLearnings.cjs`: +```js +'use strict'; + +function filterFindings(findings, approved) { + const sigs = new Set((approved || []).filter((l) => l.kind === 'suppress').map((l) => l.signature)); + const kept = []; + const suppressed = []; + for (const f of findings) { + if (sigs.has(f.signature)) suppressed.push(f); + else kept.push(f); + } + return { kept, suppressed }; +} + +function rulesFromLearnings(approved) { + return (approved || []) + .filter((l) => l.kind === 'rule') + .map((l) => ({ paths: l.paths || [], ruleText: l.ruleText || l.example || '', agent: l.agent })); +} + +module.exports = { filterFindings, rulesFromLearnings }; +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/applyLearnings.cjs .claude/review/engine/applyLearnings.test.cjs +git -C commit --no-verify -m "feat(review): apply approved learnings (suppress + rule injection)" +``` + +--- + +### Task 4: Learnings store + CLI (`learningsStore.cjs`) + +**Files:** +- Create: `.claude/review/engine/learningsStore.cjs` +- Create: `.claude/review/engine/learningsStore.test.cjs` + +**Interfaces:** +- Consumes: `signature` (Task 1), `mineLearnings` (Task 2), `filterFindings`/`rulesFromLearnings` (Task 3), `yaml`. +- Produces helpers: `mergeProposals(existing, proposals) -> { version, learnings }` (adds new by `id`, never overwrites an existing entry's `status`); `parsePending(yamlText) -> entries[]` (only findings with a filled `outcome`); `loadApproved(learnings) -> entries[]`; IO `loadLearnings`/`saveLearnings`/`loadFeedback`/`appendFeedback`. CLI modes: `--emit --in --review `, `--ingest `, `--mine [--min-support N]`, `--rules`, `--filter --in `. + +- [ ] **Step 1: Write the failing test** + +Create `.claude/review/engine/learningsStore.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { mergeProposals, parsePending, loadApproved } = require('./learningsStore.cjs'); + +test('mergeProposals adds new and preserves existing status', () => { + const existing = { version: 1, learnings: [{ id: 'L-a', kind: 'suppress', status: 'approved' }] }; + const proposals = [{ id: 'L-a', kind: 'suppress', status: 'proposed' }, { id: 'L-b', kind: 'rule', status: 'proposed' }]; + const merged = mergeProposals(existing, proposals); + const a = merged.learnings.find((l) => l.id === 'L-a'); + const b = merged.learnings.find((l) => l.id === 'L-b'); + assert.equal(a.status, 'approved'); // preserved, not reset to proposed + assert.equal(b.status, 'proposed'); // newly added + assert.equal(merged.learnings.length, 2); +}); + +test('parsePending keeps only findings with a filled outcome', () => { + const yamlText = ` +reviewId: r1 +findings: + - { id: f1, signature: s1, agent: ux, file: src/a.tsx, message: m1, outcome: dismissed } + - { id: f2, signature: s2, agent: ux, file: src/b.tsx, message: m2, outcome: "" } + - { id: f3, signature: s3, agent: ux, file: src/c.tsx, message: m3, outcome: accepted } +`; + const entries = parsePending(yamlText); + assert.deepEqual(entries.map((e) => e.id), ['f1', 'f3']); + assert.equal(entries[0].reviewId, 'r1'); + assert.equal(entries[0].outcome, 'dismissed'); +}); + +test('loadApproved filters by status', () => { + const learnings = { version: 1, learnings: [{ id: 'L-a', status: 'approved' }, { id: 'L-b', status: 'proposed' }] }; + assert.deepEqual(loadApproved(learnings).map((l) => l.id), ['L-a']); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `yarn --cwd test:review` → FAIL (`./learningsStore.cjs` not found). + +- [ ] **Step 3: Write minimal implementation** + +Create `.claude/review/engine/learningsStore.cjs`: +```js +'use strict'; +const { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync } = require('node:fs'); +const { join, dirname } = require('node:path'); +const YAML = require('yaml'); +const { signature } = require('./findingSignature.cjs'); +const { mineLearnings } = require('./mineLearnings.cjs'); +const { filterFindings, rulesFromLearnings } = require('./applyLearnings.cjs'); + +function mergeProposals(existing, proposals) { + const out = { version: 1, learnings: [...((existing && existing.learnings) || [])] }; + const ids = new Set(out.learnings.map((l) => l.id)); + for (const p of proposals) { + if (!ids.has(p.id)) { + out.learnings.push(p); + ids.add(p.id); + } // existing entries keep their status + } + return out; +} + +function parsePending(yamlText) { + const doc = YAML.parse(yamlText) || {}; + const out = []; + for (const f of doc.findings || []) { + if (f.outcome === 'accepted' || f.outcome === 'dismissed') { + out.push({ + reviewId: doc.reviewId, id: f.id, signature: f.signature, agent: f.agent, + category: f.category, severity: f.severity, file: f.file, message: f.message, outcome: f.outcome, + }); + } + } + return out; +} + +function loadApproved(learnings) { + return ((learnings && learnings.learnings) || []).filter((l) => l.status === 'approved'); +} + +function loadLearnings(path) { + return existsSync(path) ? YAML.parse(readFileSync(path, 'utf8')) || { version: 1, learnings: [] } : { version: 1, learnings: [] }; +} +function saveLearnings(path, obj) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, YAML.stringify(obj)); +} +function loadFeedback(path) { + if (!existsSync(path)) return []; + return readFileSync(path, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)); +} +function appendFeedback(path, entries) { + mkdirSync(dirname(path), { recursive: true }); + for (const e of entries) appendFileSync(path, JSON.stringify(e) + '\n'); +} + +module.exports = { mergeProposals, parsePending, loadApproved, loadLearnings, saveLearnings, loadFeedback, appendFeedback }; + +if (require.main === module) { + const argv = process.argv.slice(2); + const base = join(process.cwd(), '.claude/review/learnings'); + const feedbackPath = join(base, 'feedback.jsonl'); + const learningsPath = join(base, 'learnings.yml'); + const findingsPath = join(base, 'findings.json'); + const flag = (n) => { const i = argv.indexOf(n); return i >= 0 ? argv[i + 1] : undefined; }; + + if (argv.includes('--emit')) { + const raw = JSON.parse(readFileSync(flag('--in'), 'utf8')); + const reviewId = flag('--review') || 'review'; + const findings = (raw.findings || raw).map((f, i) => ({ id: `f${i + 1}`, signature: signature(f), ...f })); + mkdirSync(join(base, 'pending'), { recursive: true }); + writeFileSync(findingsPath, JSON.stringify({ reviewId, findings }, null, 2)); + const pending = { reviewId, findings: findings.map((f) => ({ id: f.id, signature: f.signature, agent: f.agent, category: f.category, severity: f.severity, file: f.file, message: f.message, outcome: '' })) }; + writeFileSync(join(base, 'pending', `${reviewId}.yml`), YAML.stringify(pending)); + process.stdout.write(`Emitted ${findings.length} findings; pending/${reviewId}.yml\n`); + } else if (argv.includes('--ingest')) { + const pendingFile = argv[argv.indexOf('--ingest') + 1]; + const entries = parsePending(readFileSync(pendingFile, 'utf8')).map((e) => ({ ts: new Date().toISOString(), ...e })); + appendFeedback(feedbackPath, entries); + process.stdout.write(`Ingested ${entries.length} outcomes\n`); + } else if (argv.includes('--mine')) { + const minSupport = flag('--min-support') ? Number(flag('--min-support')) : 3; + const proposals = mineLearnings(loadFeedback(feedbackPath), { minSupport }); + const merged = mergeProposals(loadLearnings(learningsPath), proposals); + saveLearnings(learningsPath, merged); + process.stdout.write(`Mined ${proposals.length} proposals; ${merged.learnings.length} total\n`); + } else if (argv.includes('--rules')) { + process.stdout.write(JSON.stringify(rulesFromLearnings(loadApproved(loadLearnings(learningsPath))), null, 2) + '\n'); + } else if (argv.includes('--filter')) { + const raw = JSON.parse(readFileSync(flag('--in') || findingsPath, 'utf8')); + process.stdout.write(JSON.stringify(filterFindings(raw.findings || raw, loadApproved(loadLearnings(learningsPath))), null, 2) + '\n'); + } else { + process.stdout.write('usage: learningsStore.cjs [--emit --in --review | --ingest | --mine [--min-support N] | --rules | --filter --in ]\n'); + } +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `yarn --cwd test:review` → PASS. + +- [ ] **Step 5: Commit** + +```bash +git -C add .claude/review/engine/learningsStore.cjs .claude/review/engine/learningsStore.test.cjs +git -C commit --no-verify -m "feat(review): learnings store + emit/ingest/mine/rules/filter CLI" +``` + +--- + +### Task 5: Config, schema, gitignore, scripts, seed files, command wiring + +**Files:** +- Modify: `.claude/review/config.schema.json` (add `min_support` to `learning`) +- Modify: `.claude/review/config.yml` (flip `learning.enabled`, add `min_support`) +- Modify: `.claude/review/engine/realConfig.test.cjs` (update learning assertions) +- Modify: `.gitignore` +- Modify: `package.json` (scripts) +- Create: `.claude/review/learnings/feedback.jsonl` (empty), `.claude/review/learnings/learnings.yml` (seed) +- Modify: `.claude/commands/agent-review.md` (Stage 1 + Stage 6) + +**Interfaces:** Consumes `learningsStore.cjs` CLI (Task 4). + +- [ ] **Step 1: Add `min_support` to the schema's `learning` block** + +In `.claude/review/config.schema.json`, inside `properties.learning.properties`, add: +```json +"min_support": { "type": "integer", "minimum": 1 } +``` +(Place it alongside `enabled`/`path`/`approval_required`/`scope`. The block keeps `additionalProperties: false`.) + +- [ ] **Step 2: Update `config.yml` learning block** + +Replace the multi-line `learning:` block in `.claude/review/config.yml` with: +```yaml +learning: + enabled: true + path: ".claude/review/learnings" + approval_required: true + min_support: 3 + scope: local +``` + +- [ ] **Step 3: Update the realConfig test to match** + +In `.claude/review/engine/realConfig.test.cjs`, change the learning assertions (currently +`assert.equal(cfg.learning.enabled, false);`) to: +```js + assert.equal(cfg.learning.enabled, true); + assert.equal(cfg.learning.approval_required, true); + assert.equal(cfg.learning.min_support, 3); +``` + +- [ ] **Step 4: Gitignore transient artifacts; add scripts; seed committed files** + +Append to the worktree-root `.gitignore`: +``` +.claude/review/learnings/pending/ +.claude/review/learnings/findings.json +``` +In `package.json` `scripts`, add: +```json +"review:feedback": "yarn node .claude/review/engine/learningsStore.cjs --ingest", +"review:learn": "yarn node .claude/review/engine/learningsStore.cjs --mine" +``` +Create `.claude/review/learnings/feedback.jsonl` as an **empty file**. Create +`.claude/review/learnings/learnings.yml` with: +```yaml +version: 1 +learnings: [] +``` + +- [ ] **Step 5: Run the suite (config + test still green)** + +Run: `yarn --cwd test:review` +Expected: PASS — `realConfig.test.cjs` now asserts `learning.enabled: true` + `min_support: 3`, and the config validates against the updated schema. + +- [ ] **Step 6: Wire the command (Stage 1 + Stage 6)** + +In `.claude/commands/agent-review.md`: +- **Stage 1 (before launching agents):** add, gated on learning being enabled: +```bash +REVIEW_DIR=".claude/review" +if grep -q "learning:" "$REVIEW_DIR/config.yml" 2>/dev/null; then + yarn node "$REVIEW_DIR/engine/learningsStore.cjs" --rules > /tmp/review_rules.json 2>/dev/null || echo "[]" > /tmp/review_rules.json +fi +``` + Then instruct: for each entry in `/tmp/review_rules.json` (`{ paths, ruleText, agent }`), inject + `ruleText` into the prompt of the matching agent (same mechanism as `path_rules`). +- **Stage 6 (after consensus):** instruct the command to write the consensus findings as JSON to + `/tmp/consensus_findings.json` (array of `{ agent, category, severity, file, line, message }`), then: +```bash +yarn node "$REVIEW_DIR/engine/learningsStore.cjs" --emit --in /tmp/consensus_findings.json --review "${REVIEW_ID:-local}" +yarn node "$REVIEW_DIR/engine/learningsStore.cjs" --filter --in .claude/review/learnings/findings.json > /tmp/review_filtered.json +``` + Report the `kept` findings from `/tmp/review_filtered.json` and note the count of `suppressed` + (suppressed by approved learnings). Tell the user they can mark outcomes in the emitted + `pending/.yml`, then run `yarn review:feedback ` and `yarn review:learn`. + Leave `plan.cjs`, the index, agent selection, and debate/consensus logic unchanged. + +- [ ] **Step 7: Commit** + +```bash +git -C add .claude/review/config.schema.json .claude/review/config.yml .claude/review/engine/realConfig.test.cjs .gitignore package.json .claude/review/learnings/feedback.jsonl .claude/review/learnings/learnings.yml .claude/commands/agent-review.md +git -C commit --no-verify -m "feat(review): enable learning layer + wire feedback/learn into command" +``` + +--- + +### Task 6: End-to-end smoke + final verification + +**Files:** none (verification only; may write throwaway files under `/tmp`). + +- [ ] **Step 1: Full suite green** + +Run: `yarn --cwd test:review` +Expected: PASS — all engine tests (Phases A + B + C). + +- [ ] **Step 2: End-to-end learning-loop smoke** + +```bash +WT= +# 1. emit findings from a synthetic consensus set +cat > /tmp/cf.json <<'JSON' +{ "findings": [ + { "agent": "ux", "category": "i18n", "severity": 4, "file": "src/components/Foo/A.tsx", "message": "Hardcoded string 'Save' not localized at line 10" }, + { "agent": "ux", "category": "i18n", "severity": 4, "file": "src/components/Bar/B.tsx", "message": "Hardcoded string 'Cancel' not localized at line 22" } +] } +JSON +yarn --cwd "$WT" node .claude/review/engine/learningsStore.cjs --emit --in /tmp/cf.json --review smoke +# 2. simulate dismissing both (mark outcomes) and ingest 3x to clear minSupport +node -e "const fs=require('fs');const p='$WT/.claude/review/learnings/pending/smoke.yml';const YAML=require('$WT/.yarn');" 2>/dev/null || true +``` +(The exact marking can be done by editing `pending/smoke.yml` outcomes to `dismissed` and running +`yarn --cwd "$WT" review:feedback "$WT/.claude/review/learnings/pending/smoke.yml"` three times against +re-emitted templates, OR by appending three dismissed entries with the same signature directly to +`feedback.jsonl` for the smoke. Then:) +```bash +yarn --cwd "$WT" review:learn # mines proposals into learnings.yml +cat "$WT/.claude/review/learnings/learnings.yml" # confirm a 'suppress' proposal exists +# approve it: edit status: proposed -> approved for that entry, then: +yarn --cwd "$WT" node .claude/review/engine/learningsStore.cjs --rules +yarn --cwd "$WT" node .claude/review/engine/learningsStore.cjs --filter --in .claude/review/learnings/findings.json +``` +Expected: after mining, `learnings.yml` contains a `kind: suppress` proposal for the repeated i18n +signature; after flipping it to `approved`, `--filter` moves the matching finding into `suppressed`. + +- [ ] **Step 3: Confirm gitignore + tracked files** + +```bash +git -C status --short +git -C check-ignore .claude/review/learnings/pending/smoke.yml .claude/review/learnings/findings.json +git -C ls-files .claude/review/learnings +``` +Expected: `pending/` and `findings.json` are ignored (printed by `check-ignore`); `ls-files` shows +`feedback.jsonl` and `learnings.yml` tracked. Working tree clean except intended committed changes. +Revert any smoke mutations to committed files (`git -C checkout -- .claude/review/learnings/feedback.jsonl .claude/review/learnings/learnings.yml`) so the committed seeds stay empty. + +- [ ] **Step 4: lint:ts unaffected** + +Run: `yarn --cwd lint:ts` +Expected: no NEW engine-related errors (pre-existing app codegen errors OK; `.claude` is outside tsconfig scope). + +--- + +## Self-Review + +**1. Spec coverage:** +- Capture (signature + emit findings + pending template + ingest) → Tasks 1, 4 (`--emit`/`--ingest`) ✓ +- Mine (signatures → proposals, support/dominant thresholds) → Task 2 ✓ +- Approve (file-based status gate, preserve status on re-mine) → Task 4 (`mergeProposals`) + Task 5 ✓ +- Apply (suppress + rule injection) → Task 3 + Task 5 Stage 1/6 wiring ✓ +- Config + schema (`learning.enabled`, `min_support`) → Task 5 ✓ +- Scripts (`review:feedback`, `review:learn`) → Task 5 ✓ +- Storage (commit `feedback.jsonl`/`learnings.yml`; gitignore `pending/`+`findings.json`) → Task 5 ✓ +- `plan.cjs`/index/debate-consensus logic unchanged → respected ✓ +- Acceptance criteria 1–6 → Tasks 1–6 ✓ + +**2. Placeholder scan:** No TBD/TODO. Every code step has complete code. Task 6 Step 2's marking can be done two explicit ways (edit-and-ingest, or append dismissed entries) — both concrete, not a placeholder. + +**3. Type consistency:** `signature(finding)` (Task 1) used by `learningsStore --emit` (Task 4) and grouping in `mineLearnings` via `entry.signature` (Task 2). Proposal shape `{ id, kind, status, signature, agent, category, paths, support, rationale, example, ruleText? }` produced in Task 2, merged by `mergeProposals` (Task 4), consumed by `filterFindings`/`rulesFromLearnings` (Task 3, reading `kind`/`signature`/`paths`/`ruleText`/`example`/`agent`). `parsePending` output entry shape matches what `mineLearnings` consumes (`signature`, `outcome`, `agent`, `category`, `file`, `message`). Consistent. + +--- + +## Notes for the executor + +- No new dependencies — `yaml` already present, rest are Node built-ins. +- **Never run `node --test`** under PnP — always `yarn test:review`. +- All paths lowercase `.claude/`; all commits `--no-verify`. +- Commit `feedback.jsonl` (empty) and `learnings.yml` (seed); NEVER commit `pending/` or `findings.json`. diff --git a/docs/superpowers/plans/2026-06-24-agent-review-plugin-distribution.md b/docs/superpowers/plans/2026-06-24-agent-review-plugin-distribution.md new file mode 100644 index 0000000000..b173ae25d9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-agent-review-plugin-distribution.md @@ -0,0 +1,545 @@ +# Agent-Review Plugin & Distribution (Phase E) Implementation Plan + +> **For agentic workers:** Implement task-by-task. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Package the reviewer as a Claude Code **plugin** in a new standalone repo (`agent-review`) — generic engine with vendored deps, `/agent-review:init` + `/agent-review:run` commands, a `review` CLI bin, and a marketplace manifest — so any dev installs once and sets up any repo. + +**Architecture:** A new plain-Node repo (NOT Yarn/PnP) at `${NEW_REPO}`. The repo root plays the role MPDX's `.claude/review/` played, so the generic engine copies in with **no path edits**. Deps (`yaml`/`minimatch`/`ajv`) are installed normally and **committed (vendored)** so the plugin runs with plain `node`. Engine entry points gain a `--root` flag so commands can point them at `$CLAUDE_PROJECT_DIR`. + +**Tech Stack:** Node CommonJS `.cjs`, `node --test` (plain — no PnP runner needed), npm, `yaml`/`minimatch`/`ajv` (vendored). Claude Code plugin (manifest + marketplace). + +## Global Constraints — READ FIRST + +- **Paths:** `${NEW_REPO}` = `/Users/danielbisgrove/Documents/Web_Dev/agent-review` (created in setup before this plan runs; a fresh `git init` repo). `${SRC}` = `/Users/danielbisgrove/Documents/Web_Dev/MPDX/mpdx-react-review-config/.claude/review` (the engine source of truth to extract from). +- This repo is **plain Node + npm, NOT PnP.** Run tests with `node --test engine/` (workers resolve deps from real `node_modules` — the MPDX single-process-runner workaround is not needed). Run the engine with plain `node`. +- **Vendor deps:** `node_modules` is committed (a plugin must be self-contained). Do NOT gitignore `node_modules`. +- Engine is CommonJS `.cjs`. Commit normally (this repo has no husky); plain `git commit -m`. +- The repo root mirrors MPDX's `.claude/review/`: `cli.cjs` and `config.schema.json` at repo root; engine modules under `engine/`. This keeps every `require('../config.schema.json')` / `require('../cli.cjs')` in the copied tests valid with zero edits. +- Work ONLY in `${NEW_REPO}` (and read-only from `${SRC}`). Use absolute paths; `git -C ${NEW_REPO}`. +- Do NOT modify the mpdx-react worktree (it's the read-only source). + +--- + +### Task 1: Scaffold the plugin repo + +**Files:** +- Create: `${NEW_REPO}/package.json`, `${NEW_REPO}/.gitignore`, `${NEW_REPO}/.claude-plugin/plugin.json`, `${NEW_REPO}/README.md` +- Install + commit: `${NEW_REPO}/node_modules/` (vendored) + +- [ ] **Step 1: Initialize package + install vendored deps** + +```bash +cd ${NEW_REPO} +npm init -y +npm pkg set name="agent-review" version="0.1.0" description="Multi-agent code reviewer — Claude Code plugin" license="MIT" +npm pkg set scripts.test="node --test engine/" +npm pkg delete scripts.start 2>/dev/null || true +npm install yaml minimatch ajv +``` + +- [ ] **Step 2: Write `.gitignore` (do NOT ignore node_modules)** + +Create `${NEW_REPO}/.gitignore`: +``` +*.log +.DS_Store +/tmp/ +``` + +- [ ] **Step 3: Write the plugin manifest** + +Create `${NEW_REPO}/.claude-plugin/plugin.json`: +```json +{ + "name": "agent-review", + "description": "Multi-agent PR code review with declarative config, cross-file impact analysis, and an approval-gated learning loop.", + "version": "0.1.0", + "author": { "name": "CruGlobal" } +} +``` +(Commands are auto-discovered from `commands/` and the bin from `bin/` — no need to list them.) + +- [ ] **Step 4: Write a minimal README** + +Create `${NEW_REPO}/README.md`: +```markdown +# agent-review + +Multi-agent code reviewer, distributed as a Claude Code plugin. + +## Install +``` +/plugin marketplace add CruGlobal/agent-review +/plugin install agent-review@cru +``` + +## Use (in any repo) +``` +/agent-review:init # set up this repo (generates .claude/review/config.yml + rules/) +/agent-review:run # pre-flight + multi-agent review +review help # CLI: config / impact / learnings / approve / ... +``` +``` + +- [ ] **Step 5: Sanity test that vendored deps resolve, then commit** + +```bash +cd ${NEW_REPO} +node -e "require('yaml'); require('minimatch'); require('ajv'); console.log('deps OK')" +git -C ${NEW_REPO} add -A +git -C ${NEW_REPO} commit -m "chore: scaffold agent-review plugin repo (manifest, vendored deps)" +``` +Expected: prints `deps OK`; commit includes package.json, node_modules, manifest, README, .gitignore. + +--- + +### Task 2: Extract the generic engine + +**Files:** +- Create (copy from `${SRC}`): `${NEW_REPO}/cli.cjs`, `${NEW_REPO}/config.schema.json`, `${NEW_REPO}/engine/*.cjs` + +**Interfaces:** +- Produces: the full engine (loadConfig, scoreRisk, selectAgents, resolveRules, detectSpecial, plan, resolveImport, buildGraph, queryImpact, indexStore, impact, findingSignature, mineLearnings, applyLearnings, learningsStore, cli, cliCommands) plus the generic `config.schema.json`. + +- [ ] **Step 1: Copy the engine + schema + CLI (mirror MPDX `.claude/review/` → repo root)** + +```bash +mkdir -p ${NEW_REPO}/engine +# CLI + schema live at repo root (mirror of MPDX .claude/review/ root) +cp ${SRC}/cli.cjs ${NEW_REPO}/cli.cjs +cp ${SRC}/config.schema.json ${NEW_REPO}/config.schema.json +# all engine modules + tests +cp ${SRC}/engine/*.cjs ${NEW_REPO}/engine/ +# Drop MPDX-specific tests (they validate MPDX's real config.yml/rules, which become templates) +rm -f ${NEW_REPO}/engine/realConfig.test.cjs ${NEW_REPO}/engine/rulesCoverage.test.cjs +# run-tests.cjs (the PnP single-process runner) is unnecessary off PnP; remove it +rm -f ${NEW_REPO}/engine/run-tests.cjs +ls ${NEW_REPO}/engine/*.cjs | wc -l +``` + +- [ ] **Step 2: Run the ported suite** + +```bash +cd ${NEW_REPO} +node --test engine/ 2>&1 | tail -8 +``` +Expected: PASS — all copied generic suites green (schema, loadConfig, scoreRisk, selectAgents, +resolveRules, detectSpecial, plan, resolveImport, buildGraph, queryImpact, indexStore, impact, +findingSignature, mineLearnings, applyLearnings, learningsStore, cliCommands, cli). The MPDX-config +tests were dropped (replaced by a template-validation test in Task 5). If any test fails to resolve +`../config.schema.json` or `../cli.cjs`, confirm those two files are at the repo root (Step 1). + +- [ ] **Step 3: Commit** + +```bash +git -C ${NEW_REPO} add cli.cjs config.schema.json engine +git -C ${NEW_REPO} commit -m "feat: extract generic review engine (config/risk/index/learning/cli)" +``` + +--- + +### Task 3: `--root` plumbing for the target project + +**Files:** +- Create: `${NEW_REPO}/engine/projectRoot.cjs`, `${NEW_REPO}/engine/projectRoot.test.cjs` +- Modify: `${NEW_REPO}/cli.cjs` (derive ROOT from argv) + +**Interfaces:** +- Produces: `resolveRoot(argv) -> string` — returns the value after `--root` in argv, else `process.cwd()`. + +- [ ] **Step 1: Write the failing test** + +Create `${NEW_REPO}/engine/projectRoot.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { resolveRoot } = require('./projectRoot.cjs'); + +test('resolveRoot returns --root value when present', () => { + assert.equal(resolveRoot(['config', 'show', '--root', '/some/repo']), '/some/repo'); +}); + +test('resolveRoot defaults to process.cwd() when absent', () => { + assert.equal(resolveRoot(['config', 'show']), process.cwd()); +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd ${NEW_REPO} && node --test engine/projectRoot.test.cjs` → FAIL (module not found). + +- [ ] **Step 3: Write the helper** + +Create `${NEW_REPO}/engine/projectRoot.cjs`: +```js +'use strict'; +function resolveRoot(argv) { + const i = (argv || []).indexOf('--root'); + return i >= 0 && argv[i + 1] ? argv[i + 1] : process.cwd(); +} +module.exports = { resolveRoot }; +``` + +- [ ] **Step 4: Wire `cli.cjs` to derive ROOT from argv** + +In `${NEW_REPO}/cli.cjs`: add `const { resolveRoot } = require('./engine/projectRoot.cjs');` to the +requires. Then move the `ROOT` and dependent path constants (`RD`, `CONFIG`, `SCHEMA`, `INDEX`, +`FEEDBACK`, `LEARNINGS`) from module scope to the **top of `main(argv)`**, computing +`const ROOT = resolveRoot(argv);` first. Everything else in `main` stays identical. (When no +`--root` is passed, behavior is unchanged — defaults to cwd.) The `run` case already builds its own +paths from `ROOT`; keep them derived from the same `ROOT`. `--root

` must be accepted alongside +other flags (it is just another token in `rest`; the existing `flag()`/positional parsing ignores +unknown flags). + +- [ ] **Step 5: Run tests to verify green** + +```bash +cd ${NEW_REPO} && node --test engine/ 2>&1 | tail -6 +``` +Expected: PASS — `projectRoot.test.cjs` passes and `cli.test.cjs` (help/unknown) still passes. +Smoke: `node cli.cjs config validate --root ${NEW_REPO}` should error cleanly (no config.yml at +repo root yet) — that's expected; it proves `--root` is read. + +- [ ] **Step 6: Commit** + +```bash +git -C ${NEW_REPO} add engine/projectRoot.cjs engine/projectRoot.test.cjs cli.cjs +git -C ${NEW_REPO} commit -m "feat: --root flag so the engine targets any project dir" +``` + +--- + +### Task 4: `review` bin + `/agent-review:run` command + +**Files:** +- Create: `${NEW_REPO}/bin/review`, `${NEW_REPO}/commands/run.md` + +**Interfaces:** +- Consumes: `cli.cjs` `main` (Tasks 2-3). + +- [ ] **Step 1: Create the CLI bin** + +Create `${NEW_REPO}/bin/review`: +```js +#!/usr/bin/env node +'use strict'; +const path = require('node:path'); +const { main } = require(path.join(__dirname, '..', 'cli.cjs')); +process.exit(main(process.argv.slice(2))); +``` +Then: `chmod +x ${NEW_REPO}/bin/review`. + +- [ ] **Step 2: Smoke the bin** + +```bash +node ${NEW_REPO}/bin/review help # prints usage +node ${NEW_REPO}/bin/review nope; echo "exit=$?" # usage + exit=1 +``` +Expected: help prints the usage block; unknown command exits 1. + +- [ ] **Step 3: Write the `/agent-review:run` command** + +Create `${NEW_REPO}/commands/run.md`: +```markdown +--- +description: Pre-flight (risk + agents + impact) then launch the multi-agent code review on the current branch +--- + +You are running the agent-review reviewer on the current repository (`$CLAUDE_PROJECT_DIR`). + +1. Pre-flight (deterministic, cheap): run the engine to print the risk/agents/impact summary for the + current diff: + ```bash + node "$CLAUDE_PLUGIN_ROOT/bin/review" run --no-launch --root "$CLAUDE_PROJECT_DIR" ${ARGUMENTS:+$ARGUMENTS} + ``` + Show the user the pre-flight output (risk score/level/required reviewer, selected agents with + match reasons, blast radius). + +2. If `.claude/review/config.yml` does not exist in `$CLAUDE_PROJECT_DIR`, tell the user to run + `/agent-review:init` first and stop. + +3. Launch the full multi-agent review: read the selected agents and their rule docs from the + pre-flight plan, then perform the specialist reviews + consensus over the current diff, exactly as + the reviewer's review flow specifies. (The pre-flight already computed agent selection and rules + from `.claude/review/config.yml`.) + +Notes: the engine ships in the plugin at `$CLAUDE_PLUGIN_ROOT` and reads the user's repo via +`--root "$CLAUDE_PROJECT_DIR"`. Never write outside `$CLAUDE_PROJECT_DIR/.claude/review/`. +``` + +- [ ] **Step 4: Commit** + +```bash +git -C ${NEW_REPO} add bin/review commands/run.md +git -C ${NEW_REPO} commit -m "feat: review CLI bin + /agent-review:run command" +``` + +--- + +### Task 5: `/agent-review:init` + templates + template-validation test + +**Files:** +- Create: `${NEW_REPO}/commands/init.md`, `${NEW_REPO}/engine/templates/config.yml`, `${NEW_REPO}/engine/templates/rules/{security,architecture,data-integrity,testing,ux,standards}.md`, `${NEW_REPO}/engine/templates.test.cjs` + +**Interfaces:** +- Consumes: `loadConfig`/`validateConfig` (engine), `config.schema.json`. + +- [ ] **Step 1: Write the failing test (the template config must validate + reference existing rule docs)** + +Create `${NEW_REPO}/engine/templates.test.cjs`: +```js +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { existsSync, statSync } = require('node:fs'); +const path = require('node:path'); +const { loadConfig } = require('./loadConfig.cjs'); + +const tdir = path.join(__dirname, 'templates'); +const cfg = loadConfig({ configPath: path.join(tdir, 'config.yml'), schemaPath: path.join(__dirname, '..', 'config.schema.json') }); + +test('template config.yml validates against the schema', () => { + assert.equal(cfg.version, 1); + assert.ok(Array.isArray(cfg.agents) && cfg.agents.length >= 1); +}); + +test('every rule doc referenced by the template exists and is non-empty', () => { + const refs = new Set(); + for (const a of cfg.agents) for (const r of a.rules || []) refs.add(r); + for (const pr of cfg.path_rules || []) for (const r of pr.rules) refs.add(r); + for (const rel of refs) { + const p = path.join(tdir, rel); + assert.ok(existsSync(p), `missing template rule doc: ${rel}`); + assert.ok(statSync(p).size > 100, `template rule doc too small: ${rel}`); + } +}); +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd ${NEW_REPO} && node --test engine/templates.test.cjs` → FAIL (templates missing). + +- [ ] **Step 3: Write the generic template config** + +Create `${NEW_REPO}/engine/templates/config.yml` (a generic JS/TS-oriented starting point `init` +customizes): +```yaml +version: 1 +profile: standard + +risk: + patterns: + - { glob: "**/*auth*/**", points: 3, tier: critical } + - { glob: "**/{config,settings}.*", points: 3, tier: critical } + - { glob: "**/.github/workflows/**", points: 3, tier: critical } + - { glob: "**/migrations/**", points: 2, tier: high } + - { glob: "src/**/*.{ts,tsx,js,jsx}", points: 1, tier: medium } + - { glob: "**/*.test.{ts,tsx,js,jsx}", points: 0, tier: low } + volume_multiplier: + - { upTo: 50, points: 0 } + - { upTo: 200, points: 1 } + - { upTo: 500, points: 2 } + - { upTo: 1000, points: 3 } + - { upTo: null, points: 4 } + scope_multiplier: { single_file: 1.0, single_feature: 1.0, multi_feature: 1.3, cross_cutting: 1.7, core_infra: 2.0 } + special: + - { when: new_dependency, points: 2 } + - { when: lockfile_only_change, points: 1 } + levels: + - { range: [0, 3], level: LOW, reviewer: any } + - { range: [4, 6], level: MEDIUM, reviewer: any } + - { range: [7, 9], level: HIGH, reviewer: experienced } + - { range: [10, null], level: CRITICAL, reviewer: senior } + +agents: + - { id: security, enabled: true, triggers: { paths: ["**/*auth*/**", "**/.github/workflows/**"], content: ["process.env.", "dangerouslySetInnerHTML"] }, rules: ["rules/security.md"] } + - { id: architecture, enabled: true, always: true, rules: ["rules/architecture.md"] } + - { id: data-integrity, enabled: true, triggers: { content: ["mutation", "fetch(", "axios"] }, rules: ["rules/data-integrity.md"] } + - { id: testing, enabled: true, always: true, rules: ["rules/testing.md"] } + - { id: ux, enabled: true, triggers: { paths: ["src/**/*.{tsx,jsx}"] }, rules: ["rules/ux.md"] } + - { id: standards, enabled: true, always: true, rules: ["rules/standards.md"] } + +path_rules: [] + +excluded_paths: + - "**/*.generated.*" + - "**/dist/**" + - "**/build/**" + - "**/*.snap" + +index: { enabled: true, path: ".claude/review/index" } +learning: { enabled: true, path: ".claude/review/learnings", approval_required: true, min_support: 3, scope: local } +enforcement: { mode: warn } +``` + +- [ ] **Step 4: Write the generic starter rule docs** + +Create each of `${NEW_REPO}/engine/templates/rules/{security,architecture,data-integrity,testing,ux,standards}.md` +with a short generic H1 + a few bullet checks for that dimension (each >100 bytes). Example for +`security.md`: +```markdown +# Security Review Rules + +- Validate and sanitize all external input; never trust client-supplied values. +- No secrets in code, logs, or error messages; server-only env vars must not ship to the client. +- AuthZ on every privileged action, not just at the entry point; verify redirect/callback URLs. +- Avoid injection: parameterized queries, no string-built SQL/commands, escape rendered HTML. +``` +Write equivalent concise generic docs for `architecture.md` (boundaries, layering, dependency +direction, error handling), `data-integrity.md` (consistent reads/writes, pagination, cache +invalidation, null handling), `testing.md` (coverage of new code, edge/error cases, no flaky time), +`ux.md` (loading/error states, accessibility, i18n, responsive), and `standards.md` (naming, +exports, no debug output, no dead code, types). + +- [ ] **Step 5: Run to verify the template test passes** + +Run: `cd ${NEW_REPO} && node --test engine/ 2>&1 | tail -6` → PASS (templates validate; rule docs exist). + +- [ ] **Step 6: Write the `/agent-review:init` command** + +Create `${NEW_REPO}/commands/init.md`: +```markdown +--- +description: Set up the agent-review config + rules for the current repository (AI-tailored, dev-editable) +--- + +Initialize agent-review for the current repository (`$CLAUDE_PROJECT_DIR`). + +1. SAFETY: if `$CLAUDE_PROJECT_DIR/.claude/review/config.yml` already exists, do NOT overwrite it. + Instead, summarize what you would add/change and ask the user to merge manually. Stop. + +2. DETECT the stack: read `package.json` (manager/scripts/framework), the top-level directory + structure, the test setup, and any existing `CLAUDE.md`, `.cursorrules`, + `.github/copilot-instructions.md`, `AGENTS.md`. + +3. GENERATE a tailored config: start from the template at + `$CLAUDE_PLUGIN_ROOT/engine/templates/config.yml` and adapt it to THIS repo — set realistic + `risk.patterns` (which paths are critical/high/medium for this codebase), `agents[].triggers`, + `excluded_paths`, and `index.enabled` (true only for JS/TS repos; false otherwise). Copy the + template `rules/*.md` and fold in any rules discovered from the repo's existing + CLAUDE.md/.cursorrules files. Write everything under `$CLAUDE_PROJECT_DIR/.claude/review/` + (config.yml + rules/). Create empty `learnings/feedback.jsonl` and `learnings/learnings.yml` + (`version: 1` / `learnings: []`). Add `.claude/review/index/`, + `.claude/review/learnings/pending/`, and `.claude/review/learnings/findings.json` to the repo's + `.gitignore`. + +4. VALIDATE + INDEX: + ```bash + node "$CLAUDE_PLUGIN_ROOT/bin/review" config validate --root "$CLAUDE_PROJECT_DIR" + node "$CLAUDE_PLUGIN_ROOT/bin/review" index --root "$CLAUDE_PROJECT_DIR" # only if index.enabled + ``` + +5. REPORT what was created and the inferred critical paths, and tell the user how to OVERRIDE: edit + `.claude/review/config.yml` / `rules/*.md` directly, or use the CLI + (`review config show`, `review learnings`, `review approve `). Never write outside + `$CLAUDE_PROJECT_DIR/.claude/review/` (except the `.gitignore` additions). +``` + +- [ ] **Step 7: Commit** + +```bash +git -C ${NEW_REPO} add commands/init.md engine/templates engine/templates.test.cjs +git -C ${NEW_REPO} commit -m "feat: /agent-review:init + generic config/rules templates" +``` + +--- + +### Task 6: Marketplace + final verification + +**Files:** +- Create: `${NEW_REPO}/.claude-plugin/marketplace.json` + +- [ ] **Step 1: Write the marketplace manifest** + +Create `${NEW_REPO}/.claude-plugin/marketplace.json`: +```json +{ + "name": "cru", + "owner": { "name": "CruGlobal" }, + "plugins": [ + { + "name": "agent-review", + "source": "./", + "description": "Multi-agent PR code review with declarative config, cross-file impact analysis, and an approval-gated learning loop." + } + ] +} +``` + +- [ ] **Step 2: Validate manifests are well-formed JSON** + +```bash +node -e "JSON.parse(require('fs').readFileSync('${NEW_REPO}/.claude-plugin/plugin.json','utf8')); JSON.parse(require('fs').readFileSync('${NEW_REPO}/.claude-plugin/marketplace.json','utf8')); console.log('manifests OK')" +``` +Expected: `manifests OK`. + +- [ ] **Step 3: End-to-end `init` smoke against a throwaway TS fixture repo** + +```bash +FIX=$(mktemp -d) +mkdir -p "$FIX/src" +printf '{"name":"fixture","dependencies":{"react":"^18"}}' > "$FIX/package.json" +printf "export const add = (a:number,b:number) => a+b;\n" > "$FIX/src/util.ts" +( cd "$FIX" && git init -q ) +# Simulate what /agent-review:init does deterministically: copy template config + rules, validate, index +mkdir -p "$FIX/.claude/review/rules" "$FIX/.claude/review/learnings" +cp ${NEW_REPO}/engine/templates/config.yml "$FIX/.claude/review/config.yml" +cp ${NEW_REPO}/engine/templates/rules/*.md "$FIX/.claude/review/rules/" +printf 'version: 1\nlearnings: []\n' > "$FIX/.claude/review/learnings/learnings.yml" +: > "$FIX/.claude/review/learnings/feedback.jsonl" +node ${NEW_REPO}/bin/review config validate --root "$FIX" # -> "config OK" +node ${NEW_REPO}/bin/review index --root "$FIX" # -> "Indexed N files; ..." +node ${NEW_REPO}/bin/review impact --root "$FIX" --base HEAD 2>/dev/null || echo "(no commits to diff — expected on empty fixture)" +echo "fixture: $FIX" +rm -rf "$FIX" +``` +Expected: `config validate` → `config OK`; `index` → an "Indexed N files" line (the template config +validates and the engine runs against an arbitrary repo via `--root`). This proves the plugin +engine works on a foreign repo with only template config present. + +- [ ] **Step 4: Full suite + commit** + +```bash +cd ${NEW_REPO} && node --test engine/ 2>&1 | tail -6 # all green +git -C ${NEW_REPO} add .claude-plugin/marketplace.json +git -C ${NEW_REPO} commit -m "feat: marketplace manifest for plugin distribution" +git -C ${NEW_REPO} log --oneline +``` + +- [ ] **Step 5: Note manual install verification (cannot be scripted here)** + +Record for the user (the `/plugin` flow is interactive in Claude Code, not scriptable in this build): +> To verify install end-to-end: in Claude Code, `/plugin marketplace add ${NEW_REPO}` (local path) +> then `/plugin install agent-review@cru`, and confirm `/agent-review:init`, `/agent-review:run`, +> and `review help` are available. Publishing to a private `CruGlobal/agent-review` GitHub repo is a +> follow-up that needs org permission. + +--- + +## Self-Review + +**1. Spec coverage:** +- Plugin layout + manifest + vendored deps (plain node) → Tasks 1, 2 ✓ +- Engine extracted, generic, `node --test` → Task 2 ✓ +- `--root`/project targeting → Task 3 ✓ +- `review` bin + `/agent-review:run` → Task 4 ✓ +- `/agent-review:init` (detect → tailor → write → validate → index → override guidance) + templates → Task 5 ✓ +- Marketplace + install UX → Task 6 ✓ +- Per-repo footprint = config + rules + learnings (init writes only there) → Task 5 init command ✓ +- No-overwrite safety → Task 5 init Step 1 ✓ +- Language note (index only for JS/TS) → Task 5 init + template ✓ +- Acceptance criteria 1-7 → Tasks 1-6 ✓ + +**2. Placeholder scan:** No TBD/TODO. Engine extraction uses precise copy commands (not re-printed code — the source is the canonical worktree). Task 5 Step 4 describes the generic rule docs with one full example + explicit content per file (each gated by the template test requiring >100 bytes) — concrete, not a placeholder. The `init`/`run` commands are model-invoked markdown (prompts), which is their actual content. + +**3. Type consistency:** `resolveRoot(argv)` (Task 3) is consumed by `cli.cjs` `main`. The repo-root mirror (`cli.cjs` + `config.schema.json` at root, modules in `engine/`) keeps every copied test's `require('../config.schema.json')`/`require('../cli.cjs')` valid (Task 2). `bin/review` calls `main(process.argv.slice(2))` matching `cli.cjs`'s export. Template `config.yml` validates against the same `config.schema.json` the engine ships (Task 5 test). Commands invoke `bin/review … --root "$CLAUDE_PROJECT_DIR"`, matching Task 3's flag. + +--- + +## Notes for the executor + +- Plain Node + npm + committed `node_modules` (vendored) — NOT PnP. Tests: `node --test engine/`. +- Engine is copied from `${SRC}` unchanged (repo root mirrors MPDX `.claude/review/`); only the 2 MPDX-config tests + the PnP runner are dropped. +- `init`/`run` are model-invoked commands; the deterministic parts are the `review` bin calls with `--root "$CLAUDE_PROJECT_DIR"`. +- Live `/plugin` install verification is a manual follow-up (interactive); building + JSON-validating the manifests + the init smoke is the automated bar. diff --git a/docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md b/docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md new file mode 100644 index 0000000000..59c4a11515 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md @@ -0,0 +1,402 @@ +# Design Spec — Agent-Review Config Layer (Phase A) + +- **Date:** 2026-06-22 +- **Status:** Draft — awaiting user review +- **Author:** Daniel Bisgrove (with Claude) +- **Scope of this spec:** Phase A — the declarative **config layer** only. Index (Layer 2) and + Learning (Layer 3) are described as forward-looking architecture so the config schema reserves + room for them, but they are **out of scope for this spec** and get their own brainstorm → spec → + plan → build cycles. + +--- + +## 1. Context & Problem + +We have a working multi-agent PR reviewer at `.claude/commands/agent-review.md` (7 specialist +agents, smart selection, a debate/rebuttal/consensus loop, automated fixes, metrics) with +MPDX-specific rules in `.claude/rules/code-review.md`. + +Competitive research (`.claude/docs/competitive-research-greptile-coderabbit.md`) found that the +best-in-class tools (Greptile, CodeRabbit) beat us on three things our reviewer lacks: + +1. **A declarative config surface** — they have layered config files (`.coderabbit.yaml`, + `greptile.json`, per-path rules) a UI can edit. Ours is bash logic + one prose markdown file. +2. **A persistent codebase index** — Greptile keeps a semantic graph for cross-file impact + analysis. Our agents `grep` cold every run. +3. **A feedback-learning loop** — both learn from accepted/dismissed comments over time, gated by + human approval. Ours starts cold every run. + +The long-term goal is a CLI/UI to configure and interact with the reviewer the way those products +do. The three gaps are three subsystems with a dependency order: **config is the foundation** the +other two attach to (index needs to know what to index and where to store it; learning needs to +know where learnings persist and how approval gating works). + +### Billing & runtime constraint (decided) + +A standalone Agent-SDK CLI can technically bill a Claude Pro/Max subscription via an OAuth token, +but Anthropic **discourages it for shipped/multi-user/headless apps**, and parallel subagents burn +subscription quota quickly. Our current command already runs on the Claude plan cleanly because +**Claude Code itself is subscription-billed**. Decision: + +> **Claude Code stays the orchestrator.** We build config + index + learning as a clean, +> file-based, layered core that the Claude Code command reads today and a future CLI/UI can read +> later — deferring the SDK/API-billing question until there is a real hosted-UI need. + +Source for billing finding: Anthropic Agent SDK docs / Help Center (see References). + +--- + +## 2. Goals & Non-Goals + +### Goals (Phase A) + +- Move the machine-readable parts of `code-review.md` (risk scoring, agent definitions, triggers, + excluded paths) into a single declarative `config.yml`. +- Keep the natural-language guidance (Focus Areas, Standards Checklist) as prose `rules/*.md` docs, + referenced from config by glob — preserving today's review quality. +- Add a global **severity/verbosity profile** (`chill | standard | assertive`). +- Provide a **JSON Schema** so the config is validatable now and renderable as UI forms later. +- Refactor `agent-review.md` Stage 0–1 to read config instead of hardcoded bash — with **no + regression** in current behavior. +- Reserve inert config sections for the index and learning layers so they slot in without a + re-cut. + +### Non-Goals (this spec) + +- Building the index or learning subsystems (later specs). +- Building a CLI binary or web UI (later phases). We only make the core **UI-ready**. +- Changing the debate/rebuttal/consensus stages — they are a strength and stay as-is. +- Moving off Claude Code orchestration or onto API/OAuth billing. +- Enforcement/merge-gating behavior beyond reserving an `enforcement` config key. + +--- + +## 3. Architecture — the 3-layer core + +``` +.claude/review/ +├── config.yml # Layer 1: structured config (this spec) +├── config.schema.json # JSON Schema for config.yml — validation + future UI forms +├── rules/ # Prose rule docs (migrated Focus Areas), referenced by glob +│ ├── security.md +│ ├── architecture.md +│ ├── data-integrity.md +│ ├── testing.md +│ ├── ux.md +│ ├── financial.md +│ └── standards.md +├── index/ # Layer 2 (LATER): codebase index storage +└── learnings/ # Layer 3 (LATER): persisted learnings + approval queue +``` + +- **Orchestrator:** the existing Claude Code command. It reads `config.yml`, computes risk, selects + agents, loads referenced rule docs into agent prompts, then runs the unchanged debate/consensus + pipeline. +- **Layer independence:** config works standalone. Index and learning *attach* to config via their + reserved sections; each can be enabled/disabled without touching the others. +- **UI-readiness:** every file is plain text in the repo. A future CLI/UI reads/writes the same + files; `config.schema.json` is the contract. + +> **Placement note:** the review core lives at `.claude/review/`. The command stays at +> `.claude/commands/agent-review.md`. The legacy `.claude/rules/code-review.md` is decomposed into +> `.claude/review/config.yml` + `.claude/review/rules/*.md` and then removed (or reduced to a +> pointer) as part of migration. + +--- + +## 4. Phase A — Config Layer detail + +### 4.1 `config.yml` schema (annotated) + +```yaml +version: 1 + +# Global severity/verbosity dial (CodeRabbit `profile` model). +# chill → fewer, high-confidence findings; raises consensus threshold +# standard → current behavior (default) +# assertive → more findings, lower threshold, may be nitpicky +profile: standard + +# ── Risk scoring (migrated from code-review.md) ─────────────────────────────── +risk: + # File-pattern contributions. `tier` is descriptive; `points` drives the score. + patterns: + - { glob: "pages/api/auth/**", points: 3, tier: critical } + - { glob: "pages/api/graphql-rest.page.ts", points: 3, tier: critical } + - { glob: "pages/api/Schema/index.ts", points: 3, tier: critical } + - { glob: "src/lib/apollo/{client,link,cache,ssrClient}.ts", points: 3, tier: critical } + - { glob: "next.config.{js,ts}", points: 3, tier: critical } + - { glob: ".github/workflows/**", points: 3, tier: critical } + - { glob: ".claude/**", points: 3, tier: critical } + - { glob: "pages/api/Schema/**/*.{ts,graphql}", points: 2, tier: high } + - { glob: "src/components/Shared/**", points: 2, tier: high } + - { glob: "src/components/**/*.graphql", points: 2, tier: high } + - { glob: "src/components/**/*.{ts,tsx}", points: 1, tier: medium } + - { glob: "src/hooks/**/*.ts", points: 1, tier: medium } + - { glob: "pages/**/*.page.tsx", points: 1, tier: medium } + # Low-risk overrides (explicit 0 points) + - { glob: "**/*.test.{ts,tsx}", points: 0, tier: low } + - { glob: "public/locales/**", points: 0, tier: low } + - { glob: "**/*.snap", points: 0, tier: low } + + # Change-volume → points (lines changed across the diff). + volume_multiplier: + - { upTo: 50, points: 0 } + - { upTo: 200, points: 1 } + - { upTo: 500, points: 2 } + - { upTo: 1000, points: 3 } + - { upTo: null, points: 4 } # 1000+ + + # Scope multiplier applied to the pattern+volume subtotal. + scope_multiplier: + single_file: 1.0 + single_feature: 1.0 + multi_feature: 1.3 + cross_cutting: 1.7 + core_infra: 2.0 + + # Special detections (from code-review.md "Special Pattern Detection"). + special: + - { when: new_dependency, points: 2 } + - { when: critical_pkg_update, points: 3, + packages: [next, react, "@apollo/client", "@mui/material", formik, next-auth, typescript, graphql-codegen] } + - { when: lockfile_only_change, points: 1 } + - { when: graphql_without_codegen_check, points: 2 } + - { when: next_config_security_change, points: 2 } # rewrites/headers/CSP/image domains + - { when: apollo_cache_typepolicy_change, points: 2 } + + # Score → risk level + required reviewer (from code-review.md classification). + levels: + - { range: [0, 3], level: LOW, reviewer: entry } + - { range: [4, 6], level: MEDIUM, reviewer: entry } + - { range: [7, 9], level: HIGH, reviewer: experienced } + - { range: [10, null], level: CRITICAL, reviewer: "Caleb Cox (senior)" } + +# ── Agents (the 7 specialists, declaratively) ───────────────────────────────── +agents: + - id: security + enabled: true + model: smart # smart | opus | sonnet | haiku + always: false # if true, runs regardless of triggers + triggers: + paths: ["pages/api/**", "src/lib/apollo/{link,client,ssrClient}.ts", + "next.config.{js,ts}", "pages/_app.page.tsx", ".github/workflows/**", ".claude/**"] + content: ["process.env.", "dangerouslySetInnerHTML", "router.push("] + rules: ["rules/security.md"] + + - id: architecture + enabled: true + always: true + rules: ["rules/architecture.md"] + + - id: data-integrity + enabled: true + triggers: + paths: ["pages/api/Schema/**/*.{ts,graphql}", "src/lib/apollo/cache.ts", + "src/components/**/*.graphql", "src/graphql/rootFields.generated.ts"] + content: ["mutation", "optimisticResponse", "refetchQueries", "cache.modify", "__typename", + "first:", "after:", "pageInfo", "nodes"] + rules: ["rules/data-integrity.md"] + + - id: testing + enabled: true + always: true + rules: ["rules/testing.md"] + + - id: ux + enabled: true + triggers: + paths: ["src/components/**/*.tsx", "pages/**/*.page.tsx", "src/theme.ts", "src/theme/**"] + content: [".md`. Each file is the natural-language guidance an agent loads when its triggers +match (and that `path_rules` can attach to any agent). Content is **migrated verbatim** from +`code-review.md` (reorganized, not rewritten) to preserve current review quality. + +--- + +## 5. Command consumption & migration + +### 5.1 How `agent-review.md` consumes config + +Refactor **Stage 0–1 only**: + +- **Stage 0 (Risk):** replace the hardcoded critical/high/medium pattern lists and special-pattern + greps with logic that reads `risk.*` from `config.yml` and computes the score/level/reviewer. +- **Stage 0B (Agent selection):** replace the hardcoded `grep` triggers with logic that, for each + `agents[]` entry, runs it if `always: true` or if any `triggers.paths`/`triggers.content` match + the diff (respecting `excluded_paths`). +- **Stage 1 (Launch):** for each selected agent, load its `rules` docs (plus any matching + `path_rules`) into the prompt, and apply the `profile` cutoffs. +- **Stages 2–6 (debate, rebuttal, consensus, report, metrics):** unchanged, except Stage 5 reads + the profile-derived thresholds. + +The command may shell out to a tiny helper (e.g. a Node script using `js-yaml` + a glob matcher, +or `yq`) to parse YAML and emit the selected agents / risk score as JSON the markdown stages +consume. Parsing approach is an implementation-plan detail; the spec only requires that config is +read, validated against the schema, and drives Stage 0–1. + +### 5.2 Migration of `code-review.md` + +1. Extract risk patterns / special detections / level classification → `risk.*` in `config.yml`. +2. Extract agent trigger lists → `agents[].triggers` in `config.yml`. +3. Extract excluded paths → `excluded_paths`. +4. Move each Focus-Areas / Standards section → `rules/.md` (verbatim reorg). +5. Replace `code-review.md` with a short pointer to `.claude/review/` (or delete it). + +**Behavior-preservation requirement:** the migrated config must reproduce the same risk score, +agent selection, and rule coverage as the current `code-review.md` on a representative set of +diffs (see §7). + +--- + +## 6. Forward-looking — how Index & Learning attach (NOT built here) + +- **Index (Layer 2):** a build step produces per-function NL summaries + a symbol/caller map under + `index/`; agents query it for cross-file impact analysis and prior art. Controlled by + `index.enabled` / `index.path`. Refresh strategy (full vs incremental) is its own spec. +- **Learning (Layer 3):** the command logs findings + outcomes (accepted/dismissed/merged) to + `learnings/`; a periodic mining step proposes new rules into an **approval queue** + (`learnings/proposed/`) that a human ratifies before they are promoted into `config.yml` / + `rules/*.md`. Gated by `learning.approval_required`. Scope via `learning.scope`. Its own spec. + +These are listed so the config schema reserves their keys; **no implementation in Phase A.** + +--- + +## 7. Testing & acceptance + +### 7.1 Testing strategy + +- **Schema validation tests:** the committed `config.yml` validates against `config.schema.json`; + representative malformed configs are rejected with clear errors. +- **Risk-parity tests:** a fixture set of diffs (auth change, GraphQL change, UI-only change, + financial report change, large cross-cutting change, docs-only change) yields the **same risk + score and level** under config-driven logic as the current hardcoded logic. +- **Selection-parity tests:** the same fixtures select the **same set of agents** as today. +- **Rule-coverage check:** every section of the old `code-review.md` is present in some + `rules/*.md` (no dropped guidance). +- **Smoke test:** run `/agent-review` end-to-end on a sample PR; confirm it produces a report with + no regressions vs. a pre-migration run. + +### 7.2 Acceptance criteria (Phase A done when) + +1. `.claude/review/config.yml`, `config.schema.json`, and `rules/*.md` exist and validate. +2. `agent-review.md` reads config for Stage 0–1; debate/consensus stages unchanged. +3. Risk-parity and selection-parity fixtures pass (same outputs as pre-migration). +4. `profile` is honored (chill/standard/assertive change finding volume + thresholds). +5. `code-review.md` is decomposed and superseded (pointer or removed). +6. `index` / `learning` / `enforcement` keys exist, validate, and are inert. +7. A representative end-to-end run shows no regression. + +--- + +## 8. Sequencing & out of scope + +- **This spec:** Phase A (config layer) only. +- **Next:** Phase B (Index) — own brainstorm → spec → plan. +- **Then:** Phase C (Learning) — own brainstorm → spec → plan. +- **Later:** CLI/UI surfaces over the same files (enabled by API/OAuth billing or Anthropic + approval; deferred). + +--- + +## 9. Open questions & risks + +- **YAML parsing in a markdown command:** the command needs a reliable YAML→JSON step. Risk: extra + tooling (`yq`/Node helper). Mitigation: pick one in the implementation plan; prefer a small + committed Node script using an already-available dependency. +- **Profile threshold tuning:** exact severity cutoffs per profile need calibration; start with the + table in §4.2 and adjust after real runs. +- **Glob semantics:** must match the command's matcher behavior to the globs authors expect + (minimatch-style). Pin the matcher in the plan. +- **Scope/volume detection:** `scope_multiplier` requires classifying a diff's scope; today this is + heuristic. Keep the current heuristic, just config-drive the multipliers. + +--- + +## 10. References + +- Competitive research: `.claude/docs/competitive-research-greptile-coderabbit.md` +- Current reviewer: `.claude/commands/agent-review.md` +- Current rules: `.claude/rules/code-review.md` +- CodeRabbit config model: `profile`, `path_instructions`, `learnings` (scope + approval_delay) — + docs.coderabbit.ai/reference/configuration +- Greptile layered config + impact analysis — greptile.com/docs/code-review/custom-standards, + .../how-greptile-works/graph-based-codebase-context +- Agent SDK billing constraint — Anthropic Agent SDK docs / Help Center (subscription OAuth + discouraged for shipped/multi-user apps; API key for production) diff --git a/docs/superpowers/specs/2026-06-23-agent-review-cli-design.md b/docs/superpowers/specs/2026-06-23-agent-review-cli-design.md new file mode 100644 index 0000000000..2775cafa13 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-agent-review-cli-design.md @@ -0,0 +1,172 @@ +# Design Spec — Agent-Review CLI (Phase D) + +- **Date:** 2026-06-23 +- **Status:** Draft — awaiting user review +- **Author:** Daniel Bisgrove (with Claude) +- **Branch:** continues on `review-config-layer` (builds on Phases A + B + C) +- **Builds on:** the review core (`.claude/review/config.yml`, `engine/*.cjs`, `learnings/`). + +--- + +## 1. Context & Problem + +Phases A–C produced a clean, file-based review core (config, index/impact, learning) exposed today +through scattered commands: `yarn node .claude/review/engine/.cjs --flags`, `yarn +review:index`, `yarn review:feedback`, `yarn review:learn`, and the Claude Code `/agent-review` +command. The original long-term vision was **a CLI (and later a UI) to set up and interact with the +reviewer like Greptile/CodeRabbit**. This phase delivers the **unified `review` CLI** — one +ergonomic entry point over the core — which is also the command set a future UI backend will call. + +### Decisions already made (brainstorm) + +1. **CLI now, UI later.** This spec is the CLI only; the web UI is a later phase reusing these + commands. +2. **Manage/inspect the core + trigger Claude Code for the actual review.** The CLI does NOT + re-implement the multi-agent review (that would reopen the Agent-SDK billing problem). It manages + config/index/learning and, for `review run`, shells out to the existing `/agent-review` Claude + Code command (subscription billing, 7-agent debate intact). +3. **`review run` shells `claude -p`.** It gathers the diff, prints a cheap deterministic pre-flight + (risk/agents/impact), then invokes `claude -p "/agent-review …"` so the full review runs from one + command — fine for the user's own local interactive use. + +### Platform constraints (inherited) + +- Yarn 4 + PnP (no `node_modules`); CommonJS `.cjs`; run via `yarn node`; test via `yarn + test:review` (single-process runner; never `node --test`). Lowercase `.claude/`. Commit with + `--no-verify`. No new dependencies (`yaml` already present; rest are Node built-ins). + +--- + +## 2. Goals & Non-Goals + +### Goals + +- A single `review` CLI (`yarn review [args]`) unifying the existing core operations. +- Commands: `config show`, `config validate`, `index`, `impact`, `feedback`, `learn`, `learnings`, + `approve`, `reject`, `run`, `help`. +- `approve`/`reject` flip a learning's `status:` (convenience over hand-editing YAML). +- `run` prints a deterministic pre-flight (risk, selected agents + reasons, blast radius) and then + launches the Claude Code review via `claude -p`. +- New logic kept pure + tested; the dispatcher is thin glue. + +### Non-Goals + +- The web UI (later phase). +- Any new review/analysis logic — the CLI only orchestrates existing engine modules. +- A re-implemented multi-agent runner / Agent SDK; auth; multi-user. +- Changing `plan.cjs`, the index, the learning modules, or the debate/consensus logic. + +--- + +## 3. Architecture + +``` +.claude/review/ +├── cli.cjs # subcommand dispatcher (thin glue: argv routing, git, claude -p, fs) +├── engine/ +│ ├── cliCommands.cjs # NEW pure helpers: setLearningStatus, listLearnings, preflightSummary +│ └── … existing modules (loadConfig, buildPlan/plan, indexStore, queryImpact, impact, +│ learningsStore, …) reused as-is +``` + +- `package.json` script: `"review": "yarn node .claude/review/cli.cjs"`. Invoked as + `yarn review [args]` (yarn Berry forwards trailing args). +- `cli.cjs` requires the engine modules and `cliCommands.cjs`; it routes `argv[0]` to a handler. + All real logic lives in the (already-tested) engine modules and the (newly-tested) pure helpers; + `cli.cjs` is glue (argument parsing, `node:child_process` for git + `claude`, `node:fs`, stdout). + +--- + +## 4. Commands + +| Command | Behavior | Backing | +|---|---|---| +| `review config show` | Load + validate `config.yml`, pretty-print as JSON | `loadConfig` | +| `review config validate` | Validate vs schema; print OK or errors; **exit 1 on invalid** | `loadConfig` | +| `review index` | Rebuild the import-graph cache; print summary | `indexStore` (build path) | +| `review impact [--base ]` | Resolve diff files, build/load index, print impact report | `gitChangedFiles` + `queryImpact` | +| `review feedback ` | Ingest marked outcomes into `feedback.jsonl` | `learningsStore` ingest | +| `review learn [--min-support N]` | Mine feedback → merge proposals into `learnings.yml` | `learningsStore` mine | +| `review learnings [--status S]` | List learnings (optionally filtered by status) as a table | `cliCommands.listLearnings` | +| `review approve ` | Set learning `` status → `approved`, save | `cliCommands.setLearningStatus` | +| `review reject ` | Set learning `` status → `rejected`, save | `cliCommands.setLearningStatus` | +| `review run [--base ] [mode]` | Pre-flight summary + launch Claude Code review | §5 | +| `review help` / no args | Print usage | — | + +Unknown command → print usage, exit 1. + +--- + +## 5. `review run` + +1. **Resolve base**: `--base ` if given, else `git merge-base main HEAD` (fallback `HEAD~1`). +2. **Gather diff** to temp files: `git diff --name-only ...HEAD` (changed files), + `git diff --stat ...HEAD` (stat), `git diff ...HEAD` (full diff). +3. **Deterministic pre-flight** (cheap, no agents): `buildPlan(...)` → risk score/level/required + reviewer + selected agents with match reasons; `queryImpact(...)` → blast radius + top-impacted. + Print via `cliCommands.preflightSummary(plan, impact)`. +4. **Launch**: `execFileSync('claude', ['-p', '/agent-review ' + mode], { stdio: 'inherit' })` so the + full 7-agent debate runs on the user's subscription. `mode` defaults to `standard`. + +> Implementation detail to confirm before coding: whether `claude -p "/agent-review …"` triggers the +> slash command directly, or needs a natural-language prompt (e.g. `claude -p "Run the /agent-review +> command in mode"`). The plan will verify the exact invocation; the pre-flight (steps 1–3) +> is independent of this and always works. + +--- + +## 6. New testable logic (`engine/cliCommands.cjs`, pure) + +- `setLearningStatus(learnings, id, status) -> learnings` — returns a new learnings object with the + matching entry's `status` set; **throws `Error` if `id` not found**; leaves other entries + untouched. Used by `approve`/`reject`. +- `listLearnings(learnings, statusFilter) -> rows[]` — returns `{ id, kind, status, support, paths, + example }` rows; if `statusFilter` is given, only matching `status`. +- `preflightSummary(plan, impact) -> string` — formats a human-readable block: profile, risk + score/level/reviewer, special factors, each selected agent + `matchedBy`, blast radius, top + impacted files. Pure string builder (no I/O). + +--- + +## 7. Testing & Acceptance + +### Testing (node:test via `yarn test:review`) + +- `setLearningStatus`: flips the target's status; unknown id throws; other entries unchanged; + input not mutated (returns new object). +- `listLearnings`: no filter returns all rows with expected fields; `statusFilter` narrows results. +- `preflightSummary`: includes risk level, required reviewer, each selected agent id + matchedBy, + and the blast radius in the output string. + +### Acceptance criteria + +1. `cliCommands.cjs` exists with passing tests for the three helpers in the `yarn test:review` suite. +2. `cli.cjs` dispatcher exists; `yarn review help` prints usage; unknown command exits 1. +3. `review config show`/`validate` print the config / report validation (exit 1 on invalid). +4. `review impact`, `review index`, `review feedback`, `review learn` work via the CLI (smoke). +5. `review learnings`/`approve`/`reject` list and flip `status:` in `learnings.yml`. +6. `review run` prints the pre-flight (risk/agents/impact) and invokes `claude -p` (the invocation + is verified to trigger `/agent-review`). +7. `package.json` has the `review` script; full `yarn test:review` green; `plan.cjs`/engine logic + unchanged. + +--- + +## 8. Open questions & risks + +- **`claude -p` slash-command invocation** (see §5) — verify the exact form during implementation; + fall back to a natural-language prompt if a bare slash command isn't honored in print mode. +- **`claude` on PATH** — `review run`'s launch step assumes the Claude Code CLI is installed; if + absent, print the pre-flight + the command to run manually (graceful degradation). +- **Arg forwarding under yarn Berry** — confirm `yarn review approve L-abc` forwards `approve L-abc` + to `cli.cjs` (Berry forwards trailing args to scripts). The plan will verify with a smoke test. +- **`main` base resolution** — if `main` isn't present locally, `merge-base` fails; fall back to + `HEAD~1` and note it. + +--- + +## 9. References + +- Phases A/B/C specs + plans under `docs/superpowers/`; engine at `.claude/review/engine/`. +- Competitive research (Greptile CLI `greptile review` / `--agent`): + `.claude/docs/competitive-research-greptile-coderabbit.md`. diff --git a/docs/superpowers/specs/2026-06-23-agent-review-index-layer-design.md b/docs/superpowers/specs/2026-06-23-agent-review-index-layer-design.md new file mode 100644 index 0000000000..d96a7592d9 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-agent-review-index-layer-design.md @@ -0,0 +1,227 @@ +# Design Spec — Agent-Review Index Layer (Gap 2 / Phase B) + +- **Date:** 2026-06-23 +- **Status:** Draft — awaiting user review +- **Author:** Daniel Bisgrove (with Claude) +- **Branch:** continues on `review-config-layer` (Phase A engine is the dependency; not yet merged) +- **Builds on:** Phase A config layer (`.claude/review/`) — see + `docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md` + +--- + +## 1. Context & Problem + +Phase A gave the reviewer a declarative config layer. Gap 2 is the **persistent codebase index** — +the feature that makes Greptile best-in-class: a structural graph of the codebase used for +**cross-file impact analysis** ("this change affects these N callers/dependents"). Today the +`agent-review` command does a crude, per-run `grep` for dependents in Stage 1B; it's slow, +non-transitive, and inaccurate. + +### Decisions already made (brainstorm) + +1. **Structural impact graph, no embeddings.** Greptile's index has two halves: a structural + graph (impact analysis) and semantic embeddings (fuzzy retrieval). Embeddings need an + embeddings model — a paid external API (Voyage/OpenAI) or a local model — which conflicts with + the "use my Claude plan, no separate API account" constraint. The structural graph is the + higher-value, lower-cost half and runs locally under Node/PnP. "Semantic" retrieval is achieved + for free by having Claude (already billed via Claude Code) read the graph's neighbors. Embeddings + are out of scope. +2. **File-level import graph** ("which files import the changed file" + transitive), not + symbol-level. The repo has ~2,033 TS/TSX files with tsconfig path aliases (`src/*`, `pages/*`, + `__tests__/*`) + relative imports and `bundler` resolution — a light resolver handles this in + seconds with no TypeScript type-checker. Symbol-level precision (TS compiler API) is heavier and + deferred. +3. **Gitignored, built on demand.** The index is a local cache keyed on git HEAD; the review run + rebuilds it when missing/stale. No noisy diffs, always matches the tree. + +### Platform constraints (inherited from Phase A) + +- Yarn 4 + PnP (no `node_modules`); run via `yarn node`, test via the single-process runner + `yarn test:review` (never `node --test`). +- Engine is **CommonJS `.cjs`** (`require`/`module.exports`). +- Lowercase `.claude/` paths. Commit with `--no-verify`. + +--- + +## 2. Goals & Non-Goals + +### Goals + +- Build a persisted, accurate file-level import graph of the repo (internal edges only). +- Query the graph for direct + transitive dependents of a set of changed files, with a blast-radius + measure, depth/node caps, and the most-impacted files. +- Cache the index locally (gitignored), rebuilt when stale (git HEAD changed) or missing. +- Expose an `impact.cjs` CLI emitting an impact report JSON the command consumes. +- Integrate: replace the command's Stage 1B grep with the impact report; feed high-blast-radius + dependents to the Architecture and Data Integrity agents; surface impact in the report. Enabled + via the Phase-A reserved `index.enabled` config key. + +### Non-Goals + +- Embeddings / semantic similarity search (any kind). +- Symbol-level / call-graph precision (TS compiler API). +- Incremental rebuild (full rebuild keyed on HEAD is sufficient for now). +- Auto-bumping the risk score from blast radius (kept decoupled; impact is surfaced to agents + + report only). +- Changes to `plan.cjs` purity or the debate/consensus stages. + +--- + +## 3. Architecture + +New modules under `.claude/review/engine/` (pure + testable, except `indexStore` which is thin +fs/git glue): + +``` +.claude/review/ +├── index/ # gitignored local cache +│ └── graph.json +├── engine/ +│ ├── resolveImport.cjs # resolveImport(fromFile, spec, fileSet) -> repoRelPath | null [pure] +│ ├── buildGraph.cjs # buildGraph(files, readFile, fileSet) -> { imports, importedBy } [pure] +│ ├── queryImpact.cjs # queryImpact(changedFiles, graph, opts) -> impact report [pure] +│ ├── indexStore.cjs # loadOrBuildIndex({ repoRoot, indexPath }) -> graph (fs/git glue) +│ └── impact.cjs # CLI -> impact JSON +``` + +### Data model (`graph.json`) + +```json +{ + "version": 1, + "head": "", + "fileCount": 2033, + "imports": { "src/a.tsx": ["src/b.ts", "src/lib/c.ts"] }, + "importedBy": { "src/b.ts": ["src/a.tsx"] } +} +``` + +`imports` = internal files each file imports. `importedBy` = the reverse edges (what impact +analysis queries). External specifiers (`@mui/*`, `react`, etc.) and unresolvable specifiers are +dropped. + +--- + +## 4. Components + +### 4.1 `resolveImport(fromFile, spec, fileSet) -> string | null` (pure) + +Maps an import specifier to a repo-relative file path, or `null` if external/unresolvable. + +- **Alias**: spec starting with `src/`, `pages/`, or `__tests__/` → that repo-root-relative path + (from tsconfig `paths`). +- **Relative**: spec starting with `.` → `path.posix.join(dirname(fromFile), spec)` normalized. +- **Bare** (anything else, e.g. `react`, `@mui/material`, `lodash`) → `null` (external). +- **Resolution** (bundler semantics) against `fileSet` (a `Set` of known repo-relative files): + try the literal path, then append each of `['.ts', '.tsx', '.d.ts', '.js', '.jsx', '.json']`, + then `'/index' + ext` for the same extensions. Return the first member of `fileSet`, else `null`. + +### 4.2 `buildGraph(files, readFile, fileSet) -> { imports, importedBy }` (pure) + +- `files`: repo-relative paths to parse (TS/TSX/JS/JSX). `readFile(file) -> string` injected. + `fileSet`: `Set` of all known repo files (for resolution). +- For each file, extract specifiers with regexes: + - `/\bimport\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g` + - `/\bimport\s*['"]([^'"]+)['"]/g` (side-effect imports) + - `/\bexport\b[^'"]*?\bfrom\s*['"]([^'"]+)['"]/g` + - `/\brequire\(\s*['"]([^'"]+)['"]\s*\)/g` + - `/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g` (dynamic) +- Resolve each via `resolveImport`; keep non-null internal targets (deduped). Populate `imports` + and the reverse `importedBy`. + +### 4.3 `queryImpact(changedFiles, graph, { maxDepth = 3, maxNodes = 200 }) -> report` (pure) + +```js +{ + directDependents: { "": ["", ...], ... }, + transitiveDependents: ["", ...], // BFS over importedBy from all changed files, + // excluding the changed files themselves, + // stopping at maxDepth, capped at maxNodes + blastRadius: , // transitiveDependents.length + topImpacted: [{ file: "", dependentCount: }, ...], // sorted desc by direct count + truncated: // true if the cap was hit +} +``` + +### 4.4 `loadOrBuildIndex({ repoRoot, indexPath }) -> graph` (fs/git glue) + +- If `indexPath/graph.json` exists and its `head` equals the current git HEAD + (`git -C repoRoot rev-parse HEAD`), parse and return it. +- Otherwise rebuild: `git -C repoRoot ls-files` → filter to `*.{ts,tsx,js,jsx}` under `src/`, + `pages/`, `__tests__/` → build `fileSet` → `buildGraph(files, (f) => readFileSync(join(repoRoot, + f), 'utf8'), fileSet)` → write `graph.json` (with current `head`, `fileCount`) → return it. +- Documented limitation: index reflects HEAD, not the uncommitted working tree. Reverse-edges from + unchanged files (what impact needs) remain valid; a changed file's own new forward-edges may lag. + +### 4.5 `impact.cjs` (CLI) + +`yarn node .claude/review/engine/impact.cjs --root --index --changed [--max-depth N] [--max-nodes N]` +- Reads the newline-separated changed-files list, calls `loadOrBuildIndex` then `queryImpact`, + prints the report JSON to stdout. + +--- + +## 5. Integration + +- **Config**: set the Phase-A reserved key to `index: { enabled: true, path: ".claude/review/index" }`. +- **`.gitignore`**: add `.claude/review/index/`. +- **`package.json`**: add `"review:index": "yarn node .claude/review/engine/indexStore.cjs --build"` + (force a rebuild; `indexStore.cjs` gains a small CLI guard for `--build`). +- **Command (`agent-review.md`, Stage 1B only)**: when `index.enabled`, run `impact.cjs` against + `/tmp/changed_files.txt` → `/tmp/review_impact.json`. Replace the existing grep-based dependency + analysis with this report. Feed `topImpacted` + `directDependents` to the **Architecture** and + **Data Integrity** agent prompts ("this change affects these callers — verify them"), and surface + `blastRadius` / `topImpacted` in the final report. `plan.cjs` and the debate/consensus stages are + untouched. + +--- + +## 6. Testing & Acceptance + +### Testing (node:test via `yarn test:review`) + +- `resolveImport`: alias (`src/x` → `src/x.tsx`), relative (`../b` from `src/a/c.ts`), index + (`./dir` → `dir/index.ts`), extension precedence, external (`react` → null), unresolvable → null. +- `buildGraph`: in-memory fixture (injected `readFile` + `fileSet`) → expected `imports` / + `importedBy`; verifies dedupe and that external specifiers are dropped. +- `queryImpact`: transitive BFS, `maxDepth` / `maxNodes` capping (`truncated` flag), `blastRadius` + count, `topImpacted` ordering, changed files excluded from their own dependents. +- `indexStore`: freshness logic against a small temp fixture dir — builds when missing, reuses when + `head` matches, rebuilds when `head` differs (HEAD can be injected/stubbed for the test). + +### Acceptance criteria (Gap 2 done when) + +1. `resolveImport`, `buildGraph`, `queryImpact`, `indexStore`, `impact.cjs` exist with passing tests + in the `yarn test:review` suite. +2. `loadOrBuildIndex` builds a `graph.json` over the real repo in a few seconds and caches it + (second call with unchanged HEAD does not rebuild). +3. `impact.cjs` emits a valid impact report for a real changed-files list (smoke-tested on a real + diff). +4. `.claude/review/index/` is gitignored; `index.enabled: true` in `config.yml`; `review:index` + script present. +5. `agent-review.md` Stage 1B consumes the impact report and feeds dependents to the Architecture + + Data Integrity agents; `plan.cjs` and debate/consensus stages unchanged. +6. Full `yarn test:review` suite green; `lint:ts` shows no new engine-related errors. + +--- + +## 7. Open questions & risks + +- **Specifier-extraction false positives**: regex can match specifiers in comments/strings. Low + impact for a review heuristic; acceptable. Mitigation if needed later: strip block/line comments + before matching. +- **`.graphql` / `.generated` imports**: generated files aren't committed, so those specifiers + won't resolve and are dropped — fine; the graph covers committed TS/TSX/JS/JSX. +- **Working-tree staleness**: see §4.4 limitation. If it bites, re-parse just the changed files' + current imports on top of the cached graph (future enhancement). +- **Build cost on a cold cache**: ~2k file reads + regex; expected a few seconds. If it grows, + parallelize reads or scope the file set further. + +--- + +## 8. References + +- Phase A spec/plan: `docs/superpowers/specs/2026-06-22-agent-review-config-layer-design.md`, + `docs/superpowers/plans/2026-06-22-agent-review-config-layer.md` +- Competitive research (Greptile graph): `.claude/docs/competitive-research-greptile-coderabbit.md` +- Phase A engine + config it builds on: `.claude/review/engine/`, `.claude/review/config.yml` diff --git a/docs/superpowers/specs/2026-06-23-agent-review-learning-layer-design.md b/docs/superpowers/specs/2026-06-23-agent-review-learning-layer-design.md new file mode 100644 index 0000000000..9f8a934ad6 --- /dev/null +++ b/docs/superpowers/specs/2026-06-23-agent-review-learning-layer-design.md @@ -0,0 +1,194 @@ +# Design Spec — Agent-Review Learning Layer (Gap 3 / Phase C) + +- **Date:** 2026-06-23 +- **Status:** Draft (user pre-authorized proceeding to plan + implementation) +- **Author:** Daniel Bisgrove (with Claude) +- **Branch:** continues on `review-config-layer` (builds on Phases A + B) +- **Builds on:** config layer (`.claude/review/config.yml`, `rules/*.md`) and the engine + (`.claude/review/engine/`). + +--- + +## 1. Context & Problem + +Phases A (config) and B (index) are done. Gap 3 is the **feedback-learning loop** — the reviewer +should improve over time: stop flagging recurring false positives and strengthen coverage of +recurring real issues, the way Greptile/CodeRabbit learn from PR reactions. + +### Decisions already made (brainstorm) + +1. **Signal = explicit human marking.** Our reviewer is a local command with no PR-comment surface, + so there is no automatic reaction signal. Instead, after a review the developer marks each + finding `accepted`/`dismissed`. Deterministic, fits the local model, human already present. + (Git-derived inference and a PR-comment surface were rejected as noisy / large-scope.) +2. **Learnings do both**: recurring **dismissed** patterns → propose a `suppress`; recurring + **accepted** patterns → propose a `rule`. Both share one capture pipeline. +3. **Approval is file-based**: proposals land in `learnings.yml` as `status: proposed`; a human edits + `status:` to `approved`/`rejected`. That edit is the gate (`learning.approval_required: true`). + No auto-application. (Plain YAML a future UI can edit.) + +### Platform constraints (inherited) + +- Yarn 4 + PnP (no `node_modules`); run via `yarn node`; test via `yarn test:review` (single-process + runner; never `node --test`). +- CommonJS `.cjs`. Lowercase `.claude/` paths. Commit with `--no-verify`. +- Existing dep `yaml` is available; no new dependencies needed (signatures use built-in + `node:crypto`). + +--- + +## 2. Goals & Non-Goals + +### Goals + +- Capture per-finding outcomes (accepted/dismissed) via an explicit, file-based marking step. +- Mine recurring patterns (by a stable finding signature) into proposed learnings above a support + threshold, classified `suppress` (dismissed) or `rule` (accepted). +- Gate proposals behind human approval (`status:` edit in `learnings.yml`). +- Apply approved learnings to future reviews: suppress matching findings; inject approved rule + learnings into the relevant agents' prompts. +- Wire into the command and config; commit shared knowledge (`learnings.yml`, `feedback.jsonl`), + gitignore transient artifacts. + +### Non-Goals + +- Git-derived/automatic signal; a PR-comment surface; embeddings-based similarity (we group by + deterministic signatures, not vectors). +- A UI (the YAML files are UI-ready; the UI is a later phase). +- Changing `plan.cjs`, the index, or the debate/consensus *logic* (we only serialize findings and + filter at the boundary). + +--- + +## 3. The loop & artifacts + +``` +review → emit findings.json + pending/.yml → human fills outcomes → +review:feedback (ingest → feedback.jsonl) → review:learn (mine → learnings.yml: proposed) → +human edits status: approved → next review suppresses + injects approved learnings +``` + +Under `.claude/review/learnings/` (the Phase-A `learning.path`): +- `findings.json` — structured findings of the latest review (transient, **gitignored**). +- `pending/.yml` — marking template (transient, **gitignored**). +- `feedback.jsonl` — append-only recorded outcomes (**committed**, shared signal). +- `learnings.yml` — proposed + approved/rejected learnings (**committed**, curated knowledge). + +--- + +## 4. Components (all under `.claude/review/engine/`) + +### 4.1 `findingSignature.cjs` (pure) + +- `normalizeMessage(msg) -> string` — lowercase, strip digits, strip quoted identifiers + (`'...'`, `"..."`, `` `...` ``), collapse whitespace, trim. Generalizes + *"missing id in ContactDetails query"* and *"missing id in TaskList query"* to one form. +- `topDir(file) -> string` — first two path segments (e.g. `src/components`). +- `signature(finding) -> string` — short stable hex hash (`node:crypto` sha1, sliced) of + `agent | category | normalizeMessage(message) | topDir(file)`. + +### 4.2 `mineLearnings.cjs` (pure) + +`mineLearnings(feedbackEntries, { minSupport = 3 }) -> proposals[]` +- Group entries by `signature`. For each group with `count >= minSupport`: + - `dismissed/total >= 0.75` → `{ id, kind: 'suppress', signature, agent, category, paths: + ["/**"], support: count, rationale, example }` + - else `accepted/total >= 0.75` → `{ ..., kind: 'rule', ruleText: '' }` + - else (mixed) → no proposal. +- `id` is derived from the signature (stable) so re-mining doesn't duplicate. + +### 4.3 `applyLearnings.cjs` (pure) + +- `filterFindings(findings, approved) -> { kept, suppressed }` — drops findings whose `signature` + matches an approved `suppress` learning (optionally constrained by `paths`). +- `rulesFromLearnings(approved) -> [{ paths, ruleText, agent }]` — approved `rule` learnings as + injectable guidance. + +### 4.4 `learningsStore.cjs` (fs/yaml glue + multi-mode CLI) + +- Pure-ish helpers (tested): `mergeProposals(existing, proposals)` — adds new proposals by `id`, + **never overwrites an existing entry's `status`** (so approvals/rejections persist across + re-mining); `loadApproved(learnings)` — entries with `status === 'approved'`; `parsePending(yamlText)` + — pending template → feedback entries (only findings with a filled `outcome`). +- IO: `loadLearnings(path)`, `saveLearnings(path, obj)` (yaml), `loadFeedback(path)`, + `appendFeedback(path, entries)` (jsonl). +- **CLI modes** (`yarn node .claude/review/engine/learningsStore.cjs `): + - `--emit --in --review ` → adds `id`+`signature` to each finding, writes + `findings.json` + `pending/.yml`. + - `--ingest ` → parses filled outcomes, appends to `feedback.jsonl`. + - `--mine` → `mineLearnings(loadFeedback())` → `mergeProposals` into `learnings.yml`. + - `--rules` → prints `rulesFromLearnings(loadApproved())` as JSON. + - `--filter --in ` → prints `filterFindings(findings, loadApproved())` as JSON. + +--- + +## 5. Integration + +- **Config** (`config.yml`): `learning: { enabled: true, path: ".claude/review/learnings", + approval_required: true, min_support: 3, scope: local }`. Add `min_support` (integer) to the + schema's `learning` object (`scope` enum already exists). +- **Command (`agent-review.md`)**: + - **Stage 1 (before agents)**: when `learning.enabled`, run `learningsStore.cjs --rules` and inject + each approved rule learning's `ruleText` into the prompts of agents matching its `paths` (same + mechanism as `path_rules`). + - **Stage 6 (after consensus)**: write the consensus findings as JSON to + `/tmp/consensus_findings.json` (fields `agent, category, severity, file, line, message`), then + `learningsStore.cjs --emit` to produce `findings.json` + the pending template, then + `learningsStore.cjs --filter` to drop suppressed findings from the final report (note the + suppressed count). `plan.cjs`, the index, and the debate/consensus *logic* are unchanged. +- **Scripts** (`package.json`): `"review:feedback": "yarn node .claude/review/engine/learningsStore.cjs --ingest"` + (append the pending file path), `"review:learn": "yarn node .claude/review/engine/learningsStore.cjs --mine"`. +- **`.gitignore`**: add `.claude/review/learnings/pending/` and `.claude/review/learnings/findings.json`. + Commit `feedback.jsonl` and `learnings.yml`. + +--- + +## 6. Testing & Acceptance + +### Testing (node:test via `yarn test:review`) + +- `findingSignature`: normalization (digits/identifiers stripped; two messages differing only by an + identifier produce the same signature), `topDir`, deterministic hash. +- `mineLearnings`: below threshold → no proposal; ≥75% dismissed → `suppress`; ≥75% accepted → + `rule`; mixed → none; stable `id` from signature. +- `applyLearnings`: `filterFindings` suppresses matching signatures and keeps others (with paths + constraint); `rulesFromLearnings` maps approved rule learnings. +- `learningsStore`: `mergeProposals` adds new and preserves existing `status`; `parsePending` keeps + only filled outcomes; `loadApproved` filters; jsonl/yaml round-trip via temp files. + +### Acceptance criteria + +1. `findingSignature`, `mineLearnings`, `applyLearnings`, `learningsStore` exist with passing tests + in the `yarn test:review` suite. +2. `--emit` produces `findings.json` + a pending template from a consensus-findings JSON. +3. `--ingest` appends filled outcomes to `feedback.jsonl`; `--mine` writes proposals into + `learnings.yml`; flipping an entry to `status: approved` makes `--rules`/`--filter` honor it. +4. End-to-end smoke: synthesize a small feedback set → mine → approve → confirm `--filter` suppresses + a matching finding and `--rules` emits an approved rule. +5. Config has `learning.enabled: true` + `min_support`; schema validates it; `.gitignore` covers + transient artifacts; `feedback.jsonl`/`learnings.yml` committed (may start empty/minimal). +6. Command Stage 1 injects approved rule learnings; Stage 6 emits findings + filters suppressed; + `plan.cjs`/index/debate-consensus logic unchanged. Full `yarn test:review` green. + +--- + +## 7. Open questions & risks + +- **Signature over-generalization**: stripping all digits/identifiers could merge distinct findings. + Mitigation: signature also keys on `agent + category + topDir`, and `minSupport` (default 3) + requires repetition before any proposal. Tunable via `min_support`. +- **Consensus → JSON fidelity**: Stage 6 relies on the model emitting well-formed + `consensus_findings.json`. Mitigation: `--emit` tolerates missing optional fields (line/category) + and skips malformed entries. +- **Stale suppressions**: an approved suppress could hide a newly-real issue. Mitigation: suppressed + findings are counted/notable in the report (not silently dropped); a human can reject the learning. +- **Privacy**: `feedback.jsonl` stores finding messages about the team's own code — acceptable to + commit in this repo. + +--- + +## 8. References + +- Phases A/B specs + plans under `docs/superpowers/`; engine at `.claude/review/engine/`. +- Competitive research (CodeRabbit `learnings` scope + approval gate; Greptile rule auto-suggestion): + `.claude/docs/competitive-research-greptile-coderabbit.md`. diff --git a/docs/superpowers/specs/2026-06-24-agent-review-plugin-distribution-design.md b/docs/superpowers/specs/2026-06-24-agent-review-plugin-distribution-design.md new file mode 100644 index 0000000000..37953c6039 --- /dev/null +++ b/docs/superpowers/specs/2026-06-24-agent-review-plugin-distribution-design.md @@ -0,0 +1,233 @@ +# Design Spec — Agent-Review Plugin & Distribution (Phase E) + +- **Date:** 2026-06-24 +- **Status:** Draft — awaiting user review +- **Author:** Daniel Bisgrove (with Claude) +- **New home:** a standalone repo `CruGlobal/agent-review` (engine extracted from the mpdx-react + worktree, which is the current source of truth). This spec is drafted in mpdx-react's + `docs/superpowers/` and **moves to the new repo** when it's created. +- **Builds on:** the review core (Phases A–D) — generic engine + config + index + learning + CLI. + +--- + +## 1. Context & Problem + +The reviewer works in mpdx-react (config + index + learning + a `review` CLI, PR #1858). The goal +now is **productization**: package it so it installs once and a `review init` sets up *any* repo, +distributable to other developers. After research/brainstorm the form factor is a **Claude Code +plugin** distributed via a **private CruGlobal marketplace**. + +### Verified plugin mechanics (official docs, 2026-06-24) + +- Plugin layout: `.claude-plugin/plugin.json` manifest; `commands/` (flat `.md`), `skills/`, + `agents/`, `hooks/`, `bin/` (added to PATH while enabled); `.mcp.json` optional. +- Distribution: a marketplace repo with `.claude-plugin/marketplace.json`; install via + `/plugin marketplace add ` then `/plugin install @`. Private + GitHub repos work via `GITHUB_TOKEN`/git credential helpers. +- Path vars: `${CLAUDE_PLUGIN_ROOT}` (install dir, ephemeral — changes on update), + `${CLAUDE_PLUGIN_DATA}` (persistent `~/.claude/plugins/data/{id}/`), `${CLAUDE_PROJECT_DIR}` + (the user's repo root). Plugins are **copied to a cache** on install; `../` traversal outside the + plugin fails (so the engine must be self-contained). +- Dependencies: either vendor `node_modules` in the plugin, or install once into + `${CLAUDE_PLUGIN_DATA}` via a `SessionStart` hook. **We vendor** (3 small deps) → engine runs with + plain `node`, zero install, offline-safe. +- A plugin slash command can **write files into the user's repo** (Bash/Write, user trust level). +- `claude -p "/plugin:command"` invokes plugin commands in headless mode (confirmed; default-on). + +### Decisions (brainstorm) + +1. Form factor: **Claude Code plugin** (native to the Claude-Code/subscription runtime; single + install; bundles commands + engine + CLI bin). MCP/global-npm rejected as primary. +2. **New repo `CruGlobal/agent-review`**, engine **extracted** (it's already config-agnostic). +3. **Private CruGlobal GitHub marketplace.** +4. Engine **vendors** its deps and runs on plain `node` (no Yarn/PnP in target repos). + +--- + +## 2. Goals & Non-Goals + +### Goals + +- A `agent-review` Claude Code plugin: bundled generic engine (vendored deps) + `/agent-review:init` + + `/agent-review:run` + a `review` CLI bin. +- A private CruGlobal **marketplace** repo so any dev installs with two `/plugin` commands. +- `review init`: analyze a target repo → generate a tailored `config.yml` + `rules/*.md` + (auto-ingesting an existing `CLAUDE.md`/`.cursorrules`) → validate → build index → tell the dev how + to override (CLI or file). +- Engine extracted to the new repo, **de-PnP'd**: vendored `node_modules`, tests via plain + `node --test` (the single-process runner workaround is no longer needed off PnP). +- Per-repo footprint = `.claude/review/config.yml` + `rules/` + `learnings/` (committed) + gitignored + `index/`. No engine or deps copied into target repos. + +### Non-Goals + +- Phase F workflow integration (git hook / CI gate / the create→…→deploy flow) — separate spec. +- A web UI; a standalone global-npm CLI for use *outside* Claude Code (optional later add-on). +- New review/analysis logic — this phase packages and bootstraps what exists. +- Language-agnostic impact analysis — the import-graph stays TS/JS-only (config/risk/learning are + language-agnostic and work anywhere). + +--- + +## 3. Architecture + +### 3.1 Plugin repo layout (`CruGlobal/agent-review`) + +``` +agent-review/ # the plugin repo +├── .claude-plugin/ +│ └── plugin.json # manifest (name, version, commands, bin) +├── commands/ +│ ├── init.md # /agent-review:init (scaffold a repo) +│ └── run.md # /agent-review:run (pre-flight + 7-agent review) +├── bin/ +│ └── review # CLI entry on PATH (node shim → engine/cli.cjs) +├── engine/ # extracted generic engine (CommonJS) +│ ├── loadConfig.cjs scoreRisk.cjs selectAgents.cjs resolveRules.cjs detectSpecial.cjs plan.cjs +│ ├── resolveImport.cjs buildGraph.cjs queryImpact.cjs indexStore.cjs impact.cjs +│ ├── findingSignature.cjs mineLearnings.cjs applyLearnings.cjs learningsStore.cjs +│ ├── cli.cjs cliCommands.cjs run-tests.cjs *.test.cjs +│ └── templates/ # starter config.yml + rules/*.md templates for init +├── node_modules/ # VENDORED: yaml, minimatch, ajv (committed) +├── package.json # deps + "test": "node --test engine/" +└── README.md +``` + +A separate **marketplace repo** (e.g. `CruGlobal/agent-review-marketplace`, or a +`.claude-plugin/marketplace.json` in the same repo) lists the plugin and its source. + +### 3.2 Engine location & path resolution + +- The engine lives in the plugin at `${CLAUDE_PLUGIN_ROOT}/engine`. Commands/bin invoke + `node "$CLAUDE_PLUGIN_ROOT/engine/.cjs"`. +- The engine operates on the **target repo** via `${CLAUDE_PROJECT_DIR}` (config at + `$CLAUDE_PROJECT_DIR/.claude/review/config.yml`, etc.). All engine entry points that currently + assume `process.cwd()` gain an explicit `--root`/`--project` argument (default `process.cwd()`), + so they work both as a repo-local CLI and as a plugin reading `$CLAUDE_PROJECT_DIR`. +- Vendored `node_modules` at the plugin root means plain `node` resolves `yaml`/`minimatch`/`ajv` + with no PnP, no install, even though `${CLAUDE_PLUGIN_ROOT}` changes on update (node_modules + travels with it). + +### 3.3 Distribution / install UX + +``` +# one-time, per dev +/plugin marketplace add CruGlobal/agent-review-marketplace +/plugin install agent-review@cru +# per repo +/agent-review:init # sets up .claude/review/ for this repo +/agent-review:run # pre-flight + multi-agent review +``` + +--- + +## 4. `review init` — repo bootstrap + +`commands/init.md` (a model-invoked command) does: + +1. **Detect** stack from the target repo: package manager (yarn/npm/pnpm), framework (Next/React, + Node, etc.), test runner, top-level structure. Read any existing `CLAUDE.md`, `.cursorrules`, + `.github/copilot-instructions.md`. +2. **Generate** `$CLAUDE_PROJECT_DIR/.claude/review/config.yml` tailored to the repo — propose + `risk.patterns` (critical/high/medium globs), `agents` + triggers, `excluded_paths`, `profile`, + and the inert/enabled `index`/`learning` blocks. Seed `rules/*.md` from the detected + agent-instruction files + sensible defaults. **AI determines which paths are critical**; the dev + reviews/edits. +3. **Write** the files (Bash/Write), then run the engine to **validate** (`review config validate`) + and **build the index** if the repo is TS/JS (`review index`). +4. **Report** what was created and how to override: edit `config.yml`/`rules/*.md` directly, or use + the CLI (`review config show`, `review learnings`, `review approve `, etc.). + +Override is first-class: config is plain YAML + markdown the dev owns; the CLI is a convenience. + +### Idempotence / safety + +- If `.claude/review/config.yml` already exists, `init` does not overwrite — it prints a diff-style + summary of suggested additions and asks the dev to merge (no clobbering a customized config). +- `init` only writes under `$CLAUDE_PROJECT_DIR/.claude/review/`. + +--- + +## 5. Engine extraction & de-PnP + +- Copy the generic engine modules + tests from the mpdx-react worktree into the new repo's + `engine/`. They are already config-agnostic; the only MPDX-specific content is `config.yml` + + `rules/*.md`, which become **templates** under `engine/templates/` (generic starting points). +- Replace the PnP single-process test runner with **plain `node --test engine/`** (works off PnP) — + `run-tests.cjs` may be kept as an alias but is no longer required. +- `package.json`: declare `yaml`/`minimatch`/`ajv`; vendor `node_modules` (committed) so the plugin + is self-contained. `"test": "node --test engine/"`. +- Entry points (`cli.cjs`, `plan.cjs`, `impact.cjs`, `indexStore.cjs`, `learningsStore.cjs`) take an + explicit `--root`/`--project` (default `process.cwd()`), used by the plugin to pass + `$CLAUDE_PROJECT_DIR`. + +The mpdx-react in-repo copy (PR #1858) stays as-is until MPDX migrates to consuming the plugin +(out of scope here; a later cleanup). + +--- + +## 6. Commands & CLI surface in the plugin + +- `/agent-review:init` — §4. +- `/agent-review:run [mode]` — adapt the existing refactored `agent-review.md` to read config/engine + from the plugin + target repo; pre-flight (risk/agents/impact) then the 7-agent debate. (Reuses + the Phase A–C command logic, repointed at `$CLAUDE_PLUGIN_ROOT`/`$CLAUDE_PROJECT_DIR`.) +- `review` **bin** — the Phase-D CLI (`config`, `index`, `impact`, `feedback`, `learn`, `learnings`, + `approve`, `reject`, `run`, `help`), available on PATH inside Claude Code sessions, operating on + `$CLAUDE_PROJECT_DIR` (or `--root`). + +--- + +## 7. Testing & Acceptance + +### Testing + +- Engine tests run via `node --test engine/` in the new repo (port the existing suites — they pass + unchanged; the runner just changes). Add tests for the new `--root`/`--project` plumbing. +- `init` is validated by an integration smoke: run it against a small throwaway TS repo fixture and + assert it writes a schema-valid `config.yml` + non-empty `rules/*.md`, and that `review config + validate` passes. +- Plugin manifest validated by installing the plugin from a local marketplace path and confirming + `/agent-review:init`/`:run` and the `review` bin are available. + +### Acceptance criteria + +1. `CruGlobal/agent-review` repo exists with the plugin layout, vendored deps, and ported engine; + `node --test engine/` is green. +2. A marketplace (`.claude-plugin/marketplace.json`) lists the plugin; `/plugin marketplace add` + + `/plugin install` make `/agent-review:init`, `/agent-review:run`, and `review` available. +3. `/agent-review:init` on a fresh TS repo produces a schema-valid, tailored `config.yml` + + `rules/*.md`, validates, and builds the index — with no engine/deps copied into the repo. +4. `init` does not overwrite an existing config (merge-summary instead). +5. `/agent-review:run` (or `review run`) runs the pre-flight + launches the review via + `claude -p "/agent-review:run …"` style invocation, reading config from `$CLAUDE_PROJECT_DIR`. +6. Config override works via both the CLI and editing the files. +7. Per-repo footprint is exactly `.claude/review/{config.yml,rules/,learnings/}` (+ gitignored + `index/`). + +--- + +## 8. Open questions & risks + +- **CruGlobal repo creation/permissions** — creating `CruGlobal/agent-review` + + marketplace repos needs org permission; may start under a personal/fork and transfer. (Confirm at + implementation.) +- **Engine source-of-truth drift** — once extracted, the mpdx-react copy and the plugin diverge + until MPDX migrates. Mitigation: treat the plugin repo as canonical; MPDX migration is a tracked + follow-up. +- **`${CLAUDE_PLUGIN_ROOT}` ephemerality** — never persist absolute plugin paths; always resolve at + runtime from the env var. Vendored node_modules avoids a data-dir install dance. +- **Vendored node_modules size/licensing** — `yaml`/`minimatch`/`ajv` are small, permissively + licensed; committing them is acceptable. Re-verify on version bumps. +- **`init` config quality** — AI-generated config may miss repo-specific criticality. Mitigation: + it's a starting point the dev edits; `init` prints what it inferred and why. +- **Non-JS repos** — impact/index disabled; config/risk/learning still work. `init` detects and sets + `index.enabled` accordingly. + +--- + +## 9. References + +- Phases A–D specs/plans under `docs/superpowers/`; engine at `.claude/review/engine/`. +- Plugin docs: code.claude.com/docs/en/plugins, /plugins-reference, /plugin-marketplaces, + /headless. Competitive research: `.claude/docs/competitive-research-greptile-coderabbit.md`. diff --git a/package.json b/package.json index 0545f744be..68c8e0be6f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,11 @@ "lint:ci": "eslint '*/**/*.{js,ts,tsx}'", "lint:ts": "tsc", "test": "jest --silent", + "test:review": "yarn node .claude/review/engine/run-tests.cjs", + "review": "yarn node .claude/review/cli.cjs", + "review:index": "yarn node .claude/review/cli.cjs index --force", + "review:feedback": "yarn node .claude/review/cli.cjs feedback", + "review:learn": "yarn node .claude/review/cli.cjs learn", "test:log": "jest", "test:watch": "jest --watch", "localtest": "yarn test --runInBand --verbose", @@ -115,6 +120,7 @@ "@types/testing-library__jest-dom": "^5.14.5", "@typescript-eslint/eslint-plugin": "^7.5.0", "@typescript-eslint/parser": "^8.17.0", + "ajv": "^8.20.0", "concurrently": "^8.2.2", "css-mediaquery": "^0.1.2", "dnd-core": "^16.0.1", @@ -135,6 +141,7 @@ "jest-fetch-mock": "^3.0.3", "lighthouse": "^11.7.0", "lint-staged": "^13.0.3", + "minimatch": "^10.2.5", "next-compose-plugins": "^2.2.1", "node-mocks-http": "1.16.2", "prettier": "^3.6.2", @@ -142,7 +149,8 @@ "ts-essentials": "^9.3.0", "typescript": "~6.0.0", "url-loader": "^4.1.1", - "webpack": "^5.96.1" + "webpack": "^5.96.1", + "yaml": "^2.9.0" }, "resolutions": { "chokidar/glob-parent": "^5.1.2", diff --git a/pages/accountLists/[accountListId]/hrTools/mpdSupervisorReport/index.page.tsx b/pages/accountLists/[accountListId]/hrTools/mpdSupervisorReport/index.page.tsx deleted file mode 100644 index 8ae0bd535c..0000000000 --- a/pages/accountLists/[accountListId]/hrTools/mpdSupervisorReport/index.page.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import Head from 'next/head'; -import React, { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { blockImpersonatingNonDevelopers } from 'pages/api/utils/pagePropsHelpers'; -// The filter panel renders immediately on load (it defaults to open), so it -// must not be lazy-loaded — a dynamic import shows a spinner on the client -// while the server renders the real panel, causing a hydration mismatch. -import { MpdSupervisorReportFilterPanel } from 'src/components/HrTools/MpdSupervisorReport/Filters/MpdSupervisorReportFilterPanel'; -import { MpdSupervisorReport } from 'src/components/HrTools/MpdSupervisorReport/MpdSupervisorReport'; -import { - MpdSupervisorReportProvider, - Panel, - useMpdSupervisorReport, -} from 'src/components/HrTools/MpdSupervisorReport/MpdSupervisorReportContext'; -import { StaffMemberDrawer } from 'src/components/HrTools/MpdSupervisorReport/StaffMemberDrawer/StaffMemberDrawer'; -import { SidePanelsLayout } from 'src/components/Layouts/SidePanelsLayout'; -import Loading from 'src/components/Loading'; -import { multiPageHeaderHeight } from 'src/components/Shared/MultiPageLayout/MultiPageHeader'; -import { - MultiPageMenu, - NavTypeEnum, -} from 'src/components/Shared/MultiPageLayout/MultiPageMenu/MultiPageMenu'; -import { ReportPageWrapper } from 'src/components/Shared/styledComponents/ReportPageWrapper'; -import { useAccountListId } from 'src/hooks/useAccountListId'; -import { getAppName } from 'src/lib/getAppName'; - -const MpdSupervisorReportContent: React.FC = () => { - const { t } = useTranslation(); - const { isOpen } = useMpdSupervisorReport(); - const [panelOpen, setPanelOpen] = useState(Panel.Filters); - - const handleNavListToggle = () => { - setPanelOpen(panelOpen === Panel.Navigation ? null : Panel.Navigation); - }; - - const handleFilterListToggle = () => { - setPanelOpen(panelOpen === Panel.Filters ? null : Panel.Filters); - }; - - return ( - setPanelOpen(null)} - navType={NavTypeEnum.HrTools} - /> - ) : panelOpen === Panel.Filters ? ( - setPanelOpen(null)} /> - ) : undefined - } - leftOpen={panelOpen !== null} - leftWidth="290px" - rightPanel={} - rightOpen={isOpen} - rightWidth="60%" - headerHeight={multiPageHeaderHeight} - mainContent={ - - } - /> - ); -}; - -export const MpdSupervisorReportPage: React.FC = () => { - const { t } = useTranslation(); - const appName = getAppName(); - const accountListId = useAccountListId(); - - return ( - <> - - {`${appName} | ${t('HR Tools | MPD Supervisor Report')}`} - - {accountListId ? ( - - - - - - ) : ( - - )} - - ); -}; - -export const getServerSideProps = blockImpersonatingNonDevelopers; - -export default MpdSupervisorReportPage; diff --git a/public/locales/ar/translation.json b/public/locales/ar/translation.json index f90fe98518..cd7a58bc92 100644 --- a/public/locales/ar/translation.json +++ b/public/locales/ar/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " حتى تتمكن من تسجيل الدخول مرة أخرى باستخدام بريدك الإلكتروني الرسمي للوزارة.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " حيث يمكنك تحويل الأموال بسلاسة بين حساب موظفيك، وصندوق ادخار الموظفين، وصندوق ادخار مؤتمر الموظفين. يمكنك إجراء تحويل لمرة واحدة أو جدولة تحويل شهري تلقائي. إنها عملية سهلة للغاية، وتعتمد على الخدمة الذاتية.", - "Expenses: ": "- التحويلات الخارجية: ", - "Expenses: {{transfersOut}}": "- التحويلات الصادرة: {{transfersOut}}", + "- Transfers out: ": "- التحويلات الخارجية: ", + "- Transfers out: {{transfersOut}}": "- التحويلات الصادرة: {{transfersOut}}", "-- All Active --": "-- نشطة كلها --", "-- All Hidden --": "-- كل شيء مخفي --", "-- None --": "-- لا شيء --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* يجب عليك تضمين كل من الاسم الأول والأخير أو الاسم الكامل", "# Weeks": "# Weeks", "+ Add Mileage": "+ إضافة الأميال", - "Income: ": "+ التحويلات في: ", - "Income: {{transfersIn}}": "+ التحويلات في: {{transfersIn}}", + "+ Transfers in: ": "+ التحويلات في: ", + "+ Transfers in: {{transfersIn}}": "+ التحويلات في: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>في الحالات الخاصة التي تتجاوز فيها الطلبات الراتب المسموح به المتبقي، فإننا نطلب مراجعة إضافية من خلال الموافقات التدريجية لدينا العملية. بناءً على الطلب، قد تستغرق هذه العملية ما يصل إلى 14 يومًا. <1>بدلاً من ذلك، يمكنك تنزيل النسخة الورقية وإرسالها من طلب الراتب الإضافي إذا كنت تفضل ذلك.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/de-CH/translation.json b/public/locales/de-CH/translation.json index be3389524c..6101edcd91 100644 --- a/public/locales/de-CH/translation.json +++ b/public/locales/de-CH/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " und dich wieder mit deiner offiziellen dienstlichen E-Mail-Adresse anzumelden.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " Hier können Sie nahtlos Geld zwischen Ihrem Mitarbeiterkonto, dem Mitarbeiter-Sparfonds und dem Mitarbeiter-Konferenz-Sparfonds verschieben. Sie können eine einmalige Überweisung tätigen oder eine automatische monatliche Überweisung planen. Es ist ganz einfach und erfolgt vollständig im Self-Service.", - "Expenses: ": "- Abgänge: ", - "Expenses: {{transfersOut}}": "- Ausgehende Transfers: {{transfersOut}}", + "- Transfers out: ": "- Abgänge: ", + "- Transfers out: {{transfersOut}}": "- Ausgehende Transfers: {{transfersOut}}", "-- All Active --": "-- Alle Aktiven --", "-- All Hidden --": "-- Alle Ausgeblendeten ---", "-- None --": "-- Keine --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* Sie müssen sowohl Vor- als auch Nachname ODER den vollständigen Namen angeben", "# Weeks": "# Weeks", "+ Add Mileage": "+ Meilen hinzufügen", - "Income: ": "+ Zugänge: ", - "Income: {{transfersIn}}": "+ Überweisungen in: {{transfersIn}}", + "+ Transfers in: ": "+ Zugänge: ", + "+ Transfers in: {{transfersIn}}": "+ Überweisungen in: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>In Sonderfällen, in denen Anträge das verbleibende zulässige Gehalt überschreiten, ist eine zusätzliche Prüfung durch unser <2>Progressives Genehmigungsverfahren erforderlich. Der Prozess. Je nach Anfrage kann dies bis zu 14 Tage dauern. <1>Alternativ können Sie die <2>Papierversion herunterladen und einreichen. Falls Sie die zusätzliche Gehaltsforderung bevorzugen, können Sie diese gerne in Anspruch nehmen.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/de/translation.json b/public/locales/de/translation.json index 15bfd1ca36..a1f61bef88 100644 --- a/public/locales/de/translation.json +++ b/public/locales/de/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " und dich wieder mit deiner offiziellen dienstlichen E-Mail-Adresse anzumelden.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " Hier können Sie nahtlos Geld zwischen Ihrem Mitarbeiterkonto, dem Mitarbeiter-Sparfonds und dem Mitarbeiter-Konferenz-Sparfonds verschieben. Sie können eine einmalige Überweisung tätigen oder eine automatische monatliche Überweisung planen. Es ist ganz einfach und erfolgt vollständig im Self-Service.", - "Expenses: ": "- Abgänge: ", - "Expenses: {{transfersOut}}": "- Ausgehende Transfers: {{transfersOut}}", + "- Transfers out: ": "- Abgänge: ", + "- Transfers out: {{transfersOut}}": "- Ausgehende Transfers: {{transfersOut}}", "-- All Active --": "-- Alle Aktiven --", "-- All Hidden --": "-- Alle Ausgeblendeten ---", "-- None --": "-- Keine --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* Sie müssen sowohl Vor- als auch Nachname ODER den vollständigen Namen angeben", "# Weeks": "# Weeks", "+ Add Mileage": "+ Meilen hinzufügen", - "Income: ": "+ Zugänge: ", - "Income: {{transfersIn}}": "+ Überweisungen in: {{transfersIn}}", + "+ Transfers in: ": "+ Zugänge: ", + "+ Transfers in: {{transfersIn}}": "+ Überweisungen in: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>In Sonderfällen, in denen Anträge das verbleibende zulässige Gehalt überschreiten, ist eine zusätzliche Prüfung durch unser <2>Progressives Genehmigungsverfahren erforderlich. Der Prozess. Je nach Anfrage kann dies bis zu 14 Tage dauern. <1>Alternativ können Sie die <2>Papierversion herunterladen und einreichen. Falls Sie die zusätzliche Gehaltsforderung bevorzugen, können Sie diese gerne in Anspruch nehmen.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/en-US/translation.json b/public/locales/en-US/translation.json index 76d9635c21..c3f91bed9e 100644 --- a/public/locales/en-US/translation.json +++ b/public/locales/en-US/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " so you can log back in with your official ministry email.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.", - "Expenses: ": "Expenses: ", - "Expenses: {{transfersOut}}": "Expenses: {{transfersOut}}", + "- Transfers out: ": "- Transfers out: ", + "- Transfers out: {{transfersOut}}": "- Transfers out: {{transfersOut}}", "-- All Active --": "-- All Active --", "-- All Hidden --": "-- All Hidden --", "-- None --": "-- None --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* You need to include both First & Last Name OR Full Name", "# Weeks": "# Weeks", "+ Add Mileage": "+ Add Mileage", - "Income: ": "Income: ", - "Income: {{transfersIn}}": "Income: {{transfersIn}}", + "+ Transfers in: ": "+ Transfers in: ", + "+ Transfers in: {{transfersIn}}": "+ Transfers in: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index 952dae6b6f..159f21132d 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -12,6 +12,8 @@ " so you can log back in with your official ministry email.": " so you can log back in with your official ministry email.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.", + "- Transfers out: ": "- Transfers out: ", + "- Transfers out: {{transfersOut}}": "- Transfers out: {{transfersOut}}", "-- All Active --": "-- All Active --", "-- All Hidden --": "-- All Hidden --", "-- None --": "-- None --", @@ -173,8 +175,8 @@ "(N/A)": "(N/A)", "(Quarter)": "(Quarter)", "(Sent by {{firstName}} {{lastName}})": "(Sent by {{firstName}} {{lastName}})", - "(Subtotal + Attrition + Credit Card Fees) ÷ (1 − {{rate}}%) − (Subtotal + Attrition + Credit Card Fees)": "(Subtotal + Attrition + Credit Card Fees) ÷ (1 − {{rate}}%) − (Subtotal + Attrition + Credit Card Fees)", - "(Subtotal + Attrition) ÷ (1 - {{rate}}) - (Subtotal + Attrition)": "(Subtotal + Attrition) ÷ (1 - {{rate}}) - (Subtotal + Attrition)", + "(Subtotal + Attrition) × {{rate}}": "(Subtotal + Attrition) × {{rate}}", + "(Subtotal + Credit Card Fees + Attrition) × {{rate}}": "(Subtotal + Credit Card Fees + Attrition) × {{rate}}", "(Year)": "(Year)", "{{ combinedCap }} (with neither exceeding {{ singleCap }})": "{{ combinedCap }} (with neither exceeding {{ singleCap }})", "{{ name }} & {{ spouseName }} Combined": "{{ name }} & {{ spouseName }} Combined", @@ -239,15 +241,12 @@ "{{fieldName}} must not exceed 100%": "{{fieldName}} must not exceed 100%", "{{filters}} active filter": "{{filters}} active filter", "{{filters}} active filters": "{{filters}} active filters", - "{{from}} to {{to}}": "{{from}} to {{to}}", "{{from}}-{{to}} of {{total}}": "{{from}}-{{to}} of {{total}}", "{{fundType}}": "{{fundType}}", - "{{label}} reduced to its maximum of {{max}}.": "{{label}} reduced to its maximum of {{max}}.", - "{{label}}: {{amount}} ({{status}})": "{{label}}: {{amount}} ({{status}})", "{{max}} Maximum per year, per child": "{{max}} Maximum per year, per child", "{{max}} Maximum, per adoption, not per year": "{{max}} Maximum, per adoption, not per year", "{{message}}": "{{message}}", - "{{mode}} Minister's Housing Allowance Calculation Tool": "{{mode}} Minister's Housing Allowance Calculation Tool", + "{{mode}} Minister's Housing Allowance Request": "{{mode}} Minister's Housing Allowance Request", "{{months}} months ago": "{{months}} months ago", "{{name}} has one or multiple invalid numbers. Please fix.": "{{name}} has one or multiple invalid numbers. Please fix.", "{{name}} may request up to their Board Approved {{sectionKind}} Amount of {{approvedAmount}}.": "{{name}} may request up to their Board Approved {{sectionKind}} Amount of {{approvedAmount}}.", @@ -267,13 +266,15 @@ "* You need to include both First & Last Name OR Full Name": "* You need to include both First & Last Name OR Full Name", "# Weeks": "# Weeks", "+ Add Mileage": "+ Add Mileage", + "+ Transfers in: ": "+ Transfers in: ", + "+ Transfers in: {{transfersIn}}": "+ Transfers in: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", "<0>Our records indicate that you have an approved MHA amount. To view your MHA amount, click on the \"View Current MHA\" button below. If you would like to apply for a new MHA, click \"Update Current MHA\".": "<0>Our records indicate that you have an approved MHA amount. To view your MHA amount, click on the \"View Current MHA\" button below. If you would like to apply for a new MHA, click \"Update Current MHA\".", "<0>Our records indicate that you have not applied for Minister's Housing Allowance. If you would like information about applying for one, contact Personnel Records at <2>(407) 826-2230 or <5>MHA@cru.org.": "<0>Our records indicate that you have not applied for Minister's Housing Allowance. If you would like information about applying for one, contact Personnel Records at <2>(407) 826-2230 or <5>MHA@cru.org.", "<0>The next time the board will approve MHA Requests is {after} and your approved annual MHA amount will appear on your <4>Salary Calculation Form{approval} Once approved by the board, keep a copy for your tax records.<1><0> <2>What expenses can I claim on my MHA?": "<0>The next time the board will approve MHA Requests is {after} and your approved annual MHA amount will appear on your <4>Salary Calculation Form{approval} Once approved by the board, keep a copy for your tax records.<1><0> <2>What expenses can I claim on my MHA?", - "<0>You can now change the reminder status of any of your ministry partners online! Your current list and related information is displayed below. To change the reminder status of any of your ministry partners, use the drop-down boxes in the \"Reminder Status\" column.<1>When you're done, a \"Save\" button will appear at the top of the page. Click it to save your changes. Wondering how the <1>Reminder System works and how it differs from the Receipting System? Check out <4>Ministry Partner Reminder help.": "<0>You can now change the reminder status of any of your ministry partners online! Your current list and related information is displayed below. To change the reminder status of any of your ministry partners, use the drop-down boxes in the \"Reminder Status\" column.<1>When you're done, a \"Save\" button will appear at the top of the page. Click it to save your changes. Wondering how the <1>Reminder System works and how it differs from the Receipting System? Check out <4>Ministry Partner Reminder help.", + "<0>You can now change the reminder status of any of your ministry partners online! Your current list and related information is displayed below. To change the reminder status of any of your ministry partners, use the drop-down boxes in the \"Reminder Status\" column.<1>When you're done, click the \"Save\" button at the bottom of the page. Wondering how the <2>Reminder System works and how it differs from the Receipting System? Check out <5>Ministry Partner Reminder help.": "<0>You can now change the reminder status of any of your ministry partners online! Your current list and related information is displayed below. To change the reminder status of any of your ministry partners, use the drop-down boxes in the \"Reminder Status\" column.<1>When you're done, click the \"Save\" button at the bottom of the page. Wondering how the <2>Reminder System works and how it differs from the Receipting System? Check out <5>Ministry Partner Reminder help.", "<0>You can use this form to electronically submit additional salary requests. Please note:<1><0><0>You must have <2>adequate funds in your account to cover this request.<1><0>Your request will be reviewed and if approved, your request for this year <2>will not exceed your Remaining Allowable Salary.": "<0>You can use this form to electronically submit additional salary requests. Please note:<1><0><0>You must have <2>adequate funds in your account to cover this request.<1><0>Your request will be reviewed and if approved, your request for this year <2>will not exceed your Remaining Allowable Salary.", "Send newsletter?": "Send newsletter?", "Source: {{where}}": "Source: {{where}}", @@ -306,7 +307,6 @@ "3. Edit Your MHA": "3. Edit Your MHA", "3. Receipt": "3. Receipt", "3. Salary Calculation": "3. Salary Calculation", - "3+ months negative": "3+ months negative", "30-34": "30-34", "30-60 days late": "30-60 days late", "35-39": "35-39", @@ -320,13 +320,11 @@ "403(b) Retirement Contribution": "403(b) Retirement Contribution", "403b Contribution": "403b Contribution", "403b Contribution Percentage": "403b Contribution Percentage", - "403b Contributions": "403b Contributions", "403b Contributions if Applicable": "403b Contributions if Applicable", "5. Receipt": "5. Receipt", "60+ days late": "60+ days late", "A blue question icon indicates that the user may be active in the donation system and this account may be automatically recreated. Consider first updating the donation system.": "A blue question icon indicates that the user may be active in the donation system and this account may be automatically recreated. Consider first updating the donation system.", "A blue question icon indicates that the user may be active in the donation system and this user and account may be automatically recreated. Consider first updating the donation system.": "A blue question icon indicates that the user may be active in the donation system and this user and account may be automatically recreated. Consider first updating the donation system.", - "A breakdown of the items that make up your support goal, calculated from the information you entered in Setup and Reimbursable Expenses.": "A breakdown of the items that make up your support goal, calculated from the information you entered in Setup and Reimbursable Expenses.", "A CSV is a comma-separated spreadsheet format that can be created by many programs such as Excel, Google Sheets, Google Contacts or Numbers.": "A CSV is a comma-separated spreadsheet format that can be created by many programs such as Excel, Google Sheets, Google Contacts or Numbers.", "A Few Quick Notes About Monthly Transfers:": "A Few Quick Notes About Monthly Transfers:", "A filter with that name already exists. Do you wish to replace it?": "A filter with that name already exists. Do you wish to replace it?", @@ -357,7 +355,6 @@ "Account Number:": "Account Number:", "Account Preferences": "Account Preferences", "Account to Import From": "Account to Import From", - "Account Transfer": "Account Transfer", "Account Transfers": "Account Transfers", "Account transfers to staff members, ministries, projects, etc.": "Account transfers to staff members, ministries, projects, etc.", "Accounts": "Accounts", @@ -367,11 +364,6 @@ "Action Required": "Action Required", "Action Required:": "Action Required:", "Actions": "Actions", - "Active - FMLA Leave": "Active - FMLA Leave", - "Active - No Payroll": "Active - No Payroll", - "Active - Paid Leave": "Active - Paid Leave", - "Active - Payroll Eligible": "Active - Payroll Eligible", - "Active - Unpaid Leave": "Active - Unpaid Leave", "Activity": "Activity", "activity summary table": "activity summary table", "Add": "Add", @@ -397,7 +389,6 @@ "Add Donation": "Add Donation", "Add Email": "Add Email", "Add Email Address": "Add Email Address", - "Add End Date": "Add End Date", "Add Entry": "Add Entry", "Add Excluded Contacts To Appeal": "Add Excluded Contacts To Appeal", "Add fields to rows, columns, and values to create a pivot table": "Add fields to rows, columns, and values to create a pivot table", @@ -417,6 +408,7 @@ "Add Phone Number": "Add Phone Number", "Add Social": "Add Social", "Add star": "Add star", + "Add Stop Date": "Add Stop Date", "add tag": "add tag", "Add Tag(s)": "Add Tag(s)", "Add Tags": "Add Tags", @@ -438,7 +430,6 @@ "Additional info": "Additional info", "Additional info is required": "Additional info is required", "Additional info is required for requests exceeding your cap.": "Additional info is required for requests exceeding your cap.", - "Additional Information": "Additional Information", "Additional Salary": "Additional Salary", "Additional salary not exceeding your Maximum Allowable Salary level": "Additional salary not exceeding your Maximum Allowable Salary level", "Additional Salary on this Request": "Additional Salary on this Request", @@ -447,7 +438,6 @@ "Additional Salary Request cancelled successfully.": "Additional Salary Request cancelled successfully.", "Additional Salary Request discarded successfully.": "Additional Salary Request discarded successfully.", "Additional Salary Request Sections": "Additional Salary Request Sections", - "Additional support items beyond your base salary, including benefits, contributions, fees, and reimbursable expenses.": "Additional support items beyond your base salary, including benefits, contributions, fees, and reimbursable expenses.", "Additional Tags": "Additional Tags", "Address": "Address", "Address added successfully": "Address added successfully", @@ -457,7 +447,6 @@ "Address: ": "Address: ", "Addresses": "Addresses", "Addresses will be formatted based on country. (Experimental)": "Addresses will be formatted based on country. (Experimental)", - "Adjust the Scale to fit on one page.": "Adjust the Scale to fit on one page.", "Admin %": "Admin %", "Admin Console": "Admin Console", "Admin Cost is required": "Admin Cost is required", @@ -482,16 +471,14 @@ "All inputs have been cleared successfully": "All inputs have been cleared successfully", "All of the information for your contacts in Excel's default XLSX format.": "All of the information for your contacts in Excel's default XLSX format.", "All of the information for your contacts, best for advanced sorting/filtering and importing into other software.": "All of the information for your contacts, best for advanced sorting/filtering and importing into other software.", - "All people": "All people", "All selected contacts already have this tag": "All selected contacts already have this tag", "All Tasks": "All Tasks", - "All teams": "All teams", - "All types": "All types", "Alma Mater": "Alma Mater", "American Samoa": "American Samoa", "Amount": "Amount", "Amount ({{ currencyCode }})": "Amount ({{ currencyCode }})", "Amount ({{amount}})": "Amount ({{amount}})", + "Amount cannot exceed {{max}}": "Amount cannot exceed {{max}}", "Amount Committed": "Amount Committed", "Amount is required": "Amount is required", "Amount must be a valid number": "Amount must be a valid number", @@ -514,7 +501,6 @@ "Annual Compensation Rate": "Annual Compensation Rate", "Annual Cost of Providing a Home": "Annual Cost of Providing a Home", "Annual Fair Rental Value of your Home": "Annual Fair Rental Value of your Home", - "Annual Pay Rate": "Annual Pay Rate", "Annual Reimbursable Expenses": "Annual Reimbursable Expenses", "Annually": "Annually", "Anonymize": "Anonymize", @@ -554,7 +540,7 @@ "Are you sure you want to anonymize {{name}} in {{accountList}}?": "Are you sure you want to anonymize {{name}} in {{accountList}}?", "Are you sure you want to change selection?": "Are you sure you want to change selection?", "Are you sure you want to completely delete this tag ({{tagName}}) and remove it from all tasks?": "Are you sure you want to completely delete this tag ({{tagName}}) and remove it from all tasks?", - "Are you sure you want to delete {{goalName}}? Deleting this goal will remove it permanently.": "Are you sure you want to delete {{goalName}}? Deleting this goal will remove it permanently.", + "Are you sure you want to delete <2>{goal.name ?? t('Unnamed Goal')}? Deleting this goal will remove it permanently.": "Are you sure you want to delete <2>{goal.name ?? t('Unnamed Goal')}? Deleting this goal will remove it permanently.", "Are you sure you want to import all contacts? This may import many contacts that you do not wish to have in {{appName}}. Many users find it more helpful to use the \"Only import contacts from certain groups\" option.": "Are you sure you want to import all contacts? This may import many contacts that you do not wish to have in {{appName}}. Many users find it more helpful to use the \"Only import contacts from certain groups\" option.", "Are you sure you want to merge the selected contacts?": "Are you sure you want to merge the selected contacts?", "Are you sure you want to merge the selected people?": "Are you sure you want to merge the selected people?", @@ -563,7 +549,6 @@ "Are you sure you want to permanently delete the user: {{first}} {{last}}?": "Are you sure you want to permanently delete the user: {{first}} {{last}}?", "Are you sure you want to permanently delete this contact? Doing so will permanently delete this contacts information, as well as task history. This cannot be undone. If you wish to keep this information, you can try hiding this contact instead.": "Are you sure you want to permanently delete this contact? Doing so will permanently delete this contacts information, as well as task history. This cannot be undone. If you wish to keep this information, you can try hiding this contact instead.", "Are you sure you want to proceed?": "Are you sure you want to proceed?", - "Are you sure you want to remove {{name}} as a connection?": "Are you sure you want to remove {{name}} as a connection?", "Are you sure you want to remove {{firstName}} {{lastName}} as a coach from the account: {{accountName}}?": "Are you sure you want to remove {{firstName}} {{lastName}} as a coach from the account: {{accountName}}?", "Are you sure you want to remove {{firstName}} {{lastName}} as a user from the account: {{accountName}}?": "Are you sure you want to remove {{firstName}} {{lastName}} as a user from the account: {{accountName}}?", "Are you sure you want to remove the invite for {{email}} from the account: {{accountName}}?": "Are you sure you want to remove the invite for {{email}} from the account: {{accountName}}?", @@ -602,7 +587,6 @@ "Assessment, Benefits, Salary": "Assessment, Benefits, Salary", "Assignee": "Assignee", "Assignee: ": "Assignee: ", - "at risk": "at risk", "Attrition": "Attrition", "Australia": "Australia", "Austria": "Austria", @@ -636,13 +620,11 @@ "Balances": "Balances", "Bangladesh": "Bangladesh", "Barbados": "Barbados", - "Base": "Base", "Based on an analysis of a partner's giving history, {{appName}} can\n notify you of events that you will probably want to follow up on. The\n detection logic is based on a set of rules that are right most of the\n time, but you will still want to verify an event manually before\n contacting the partner.": "Based on an analysis of a partner's giving history, {{appName}} can\n notify you of events that you will probably want to follow up on. The\n detection logic is based on a set of rules that are right most of the\n time, but you will still want to verify an event manually before\n contacting the partner.", "Be cautious when deleting this contact, as its data may sync with Donation Services or other third-party systems. Deleting the contact will not remove it from those systems; consider hiding the contact instead.": "Be cautious when deleting this contact, as its data may sync with Donation Services or other third-party systems. Deleting the contact will not remove it from those systems; consider hiding the contact instead.", "Because of IRS and Cru requirements, the lowest salary you can request is {{minimumSalary}} ({{formula}}) for {{name}} and {{spouseMinimumSalary}} ({{spouseFormula}}) for {{spouseName}}.": "Because of IRS and Cru requirements, the lowest salary you can request is {{minimumSalary}} ({{formula}}) for {{name}} and {{spouseMinimumSalary}} ({{spouseFormula}}) for {{spouseName}}.", "Because of IRS and Cru requirements, the lowest salary you can request is {{minimumSalary}} ({{formula}}).": "Because of IRS and Cru requirements, the lowest salary you can request is {{minimumSalary}} ({{formula}}).", - "Because you or your spouse has a pending Additional Salary Request, this request requires additional approval. This will take {{timeframe}} as it needs to be signed off by the {{approver}}. This may affect your selected effective date.": "Because you or your spouse has a pending Additional Salary Request, this request requires additional approval. This will take {{timeframe}} as it needs to be signed off by the {{approver}}. This may affect your selected effective date.", - "Because your request exceeds your maximum allowable salary it will require additional approvals. For the {{requestedAmount}} you are requesting, this will take {{timeframe}} as it needs to be signed off by the {{approver}}. This may affect your selected effective date. We will review your request through our <10>Progressive Approvals process and notify you of any changes to the status of this request.": "Because your request exceeds your maximum allowable salary it will require additional approvals. For the {{requestedAmount}} you are requesting, this will take {{timeframe}} as it needs to be signed off by the {{approver}}. This may affect your selected effective date. We will review your request through our <10>Progressive Approvals process and notify you of any changes to the status of this request.", + "Because your request exceeds your maximum allowable salary it will require additional approvals. For the {{requestedAmount}} you are requesting, this will take {{timeframe}} as it needs to be signed off by the {{approver}}. This may affect your selected effective date. We will review your request through our Progressive Approvals process and notify you of any changes to the status of this request.": "Because your request exceeds your maximum allowable salary it will require additional approvals. For the {{requestedAmount}} you are requesting, this will take {{timeframe}} as it needs to be signed off by the {{approver}}. This may affect your selected effective date. We will review your request through our Progressive Approvals process and notify you of any changes to the status of this request.", "Because your request exceeds your remaining allowable salary it requires additional review. We will review your request through <2>Progressive Approvals process.<5>": "Because your request exceeds your remaining allowable salary it requires additional review. We will review your request through <2>Progressive Approvals process.<5>", "Before SECA and 403(b)": "Before SECA and 403(b)", "Belarus": "Belarus", @@ -653,6 +635,7 @@ "Benefits": "Benefits", "Benefits charges including health insurance and other staff benefits.": "Benefits charges including health insurance and other staff benefits.", "Benefits for Full-time": "Benefits for Full-time", + "Benefits is a required field": "Benefits is a required field", "Benefits must be a positive number": "Benefits must be a positive number", "Benefits Other": "Benefits Other", "Benefits Plan": "Benefits Plan", @@ -687,7 +670,6 @@ "By synchronizing your Google services with {{appName}}, you will be able\n to:": "By synchronizing your Google services with {{appName}}, you will be able\n to:", "Calculate New Salary": "Calculate New Salary", "Calculate Your MHA Request": "Calculate Your MHA Request", - "Calculating progress": "Calculating progress", "Calculation showing subtotal including net salary, taxes, and SECA contributions.": "Calculation showing subtotal including net salary, taxes, and SECA contributions.", "Calculator Settings": "Calculator Settings", "Calculator Setup": "Calculator Setup", @@ -714,12 +696,9 @@ "Cannot upload file: server error": "Cannot upload file: server error", "Cannot upload file: server not successful": "Cannot upload file: server not successful", "Cape Verde": "Cape Verde", - "car debt": "car debt", "Category": "Category", "Cayman Islands": "Cayman Islands", "Celebrations ({{totalCount}})": "Celebrations ({{totalCount}})", - "Cell Phone Number": "Cell Phone Number", - "Cell phone number is required": "Cell phone number is required", "Central African Republic": "Central African Republic", "Chad": "Chad", "Chalk Line": "Chalk Line", @@ -727,7 +706,6 @@ "Chalkline Overview": "Chalkline Overview", "Change the contact's status to: {{status}}": "Change the contact's status to: {{status}}", "Changes saved": "Changes saved", - "Changing this clears Pay Rate.": "Changing this clears Pay Rate.", "Check All": "Check All", "Check if you prefer to split your Combined Maximum Allowable Salary between you and {{ spouseName }} here before requesting your new salary.": "Check if you prefer to split your Combined Maximum Allowable Salary between you and {{ spouseName }} here before requesting your new salary.", "Checkbox selection": "Checkbox selection", @@ -805,8 +783,6 @@ "Compensation": "Compensation", "complete": "complete", "Complete": "Complete", - "Complete all fields to continue": "Complete all fields to continue", - "Complete all required fields to submit": "Complete all required fields to submit", "Complete Task": "Complete Task", "Complete Tasks": "Complete Tasks", "Complete the Form": "Complete the Form", @@ -816,6 +792,7 @@ "Completed the required IBS courses": "Completed the required IBS courses", "Completed Time": "Completed Time", "Conference / Retreat Costs": "Conference / Retreat Costs", + "Conference Savings Account": "Conference Savings Account", "Confirm": "Confirm", "Confirm {{amount}} as {{source}}": "Confirm {{amount}} as {{source}}", "Confirm All ({{value}})": "Confirm All ({{value}})", @@ -840,7 +817,6 @@ "Connected by Others": "Connected by Others", "Connecting Partner": "Connecting Partner", "Connecting Partner ": "Connecting Partner ", - "Connection removed successfully": "Connection removed successfully", "Connections": "Connections", "Connections Remaining": "Connections Remaining", "Contact": "Contact", @@ -886,15 +862,12 @@ "Copy All": "Copy All", "Cost of Providing a Home": "Cost of Providing a Home", "Costa Rica": "Costa Rica", - "Could not load required defaults. Please try again or pick Simple.": "Could not load required defaults. Please try again or pick Simple.", "Couldn't remove the admin": "Couldn't remove the admin", "Couldn't remove the invite": "Couldn't remove the invite", "Counseling": "Counseling", "Counseling that is not for the treatment of a medical condition": "Counseling that is not for the treatment of a medical condition", "Country": "Country", - "Couple in hotel/dorm room": "Couple in hotel/dorm room", "Couples": "Couples", - "Create": "Create", "Create a new contact for \"{{ name }}\"": "Create a new contact for \"{{ name }}\"", "Create a New Goal": "Create a New Goal", "Create Appeal": "Create Appeal", @@ -902,7 +875,6 @@ "Create New Tags (separate multiple tags with Enter key) *": "Create New Tags (separate multiple tags with Enter key) *", "Create Person": "Create Person", "Created:": "Created:", - "credit card debt": "credit card debt", "Credit Card Fee": "Credit Card Fee", "Credit Card Fees": "Credit Card Fees", "Croatia": "Croatia", @@ -921,8 +893,8 @@ "Current MHA (Included in Current Gross Salary)": "Current MHA (Included in Current Gross Salary)", "Current MHA Request": "Current MHA Request", "Current Reality": "Current Reality", - "Current Requested Salary": "Current Requested Salary", "Current Roth 403(b) Contribution Percent": "Current Roth 403(b) Contribution Percent", + "Current Salary": "Current Salary", "Current Salary Information": "Current Salary Information", "Current Tax-Deferred Contribution Percent": "Current Tax-Deferred Contribution Percent", "Current year's ({{year}}) salary not received due to inadequate support": "Current year's ({{year}}) salary not received due to inadequate support", @@ -949,10 +921,8 @@ "Deduction 403b Roth": "Deduction 403b Roth", "Deduction Medical": "Deduction Medical", "Deductions": "Deductions", - "Default": "Default", "Default Account": "Default Account", "Default Currency": "Default Currency", - "Default includes reimbursable expenses and 403b contributions in the goal total.": "Default includes reimbursable expenses and 403b contributions in the goal total.", "Default Primary Source:": "Default Primary Source:", "delete": "delete", "Delete": "Delete", @@ -999,7 +969,6 @@ "Digital": "Digital", "Digital Newsletter": "Digital Newsletter", "Digital Newsletter List": "Digital Newsletter List", - "Disability - Payroll Eligible": "Disability - Payroll Eligible", "Disability Earnings": "Disability Earnings", "Disability Insurance": "Disability Insurance", "Disable Calendar Integration": "Disable Calendar Integration", @@ -1016,7 +985,6 @@ "Do Not Import": "Do Not Import", "DO NOT MERGE MINISTRY ACCOUNTS THROUGH {{appName}}": "DO NOT MERGE MINISTRY ACCOUNTS THROUGH {{appName}}", "Do not move a contact into this column called \"Received\". Due to an outdated feature, contacts must first be moved to \"Committed\". If the gift has been recorded, you can then move the contact into the column called \"Given\".": "Do not move a contact into this column called \"Received\". Due to an outdated feature, contacts must first be moved to \"Committed\". If the gift has been recorded, you can then move the contact into the column called \"Given\".", - "Do you have any student loan, car, or credit card debt?": "Do you have any student loan, car, or credit card debt?", "Do you live within 50 miles of one of these major cities?": "Do you live within 50 miles of one of these major cities?", "Do you receive donations in any other country or from any other organizations?": "Do you receive donations in any other country or from any other organizations?", "Do you want to cancel your {{formTitle}}?": "Do you want to cancel your {{formTitle}}?", @@ -1060,7 +1028,7 @@ "Early Adopter": "Early Adopter", "Earnings MHA": "Earnings MHA", "Earnings Numeric": "Earnings Numeric", - "Earnings REG": "Earnings REG", + "Earnings REB": "Earnings REB", "Ecuador": "Ecuador", "Edit": "Edit", "Edit Address": "Edit Address", @@ -1069,7 +1037,6 @@ "Edit Contact Other Details": "Edit Contact Other Details", "EDIT DATE": "EDIT DATE", "Edit Donation": "Edit Donation", - "Edit End Date": "Edit End Date", "Edit Fields": "Edit Fields", "Edit Fund Transfer": "Edit Fund Transfer", "Edit Google Integration": "Edit Google Integration", @@ -1081,6 +1048,7 @@ "Edit Partnership Info": "Edit Partnership Info", "Edit Person": "Edit Person", "Edit Request": "Edit Request", + "Edit Stop Date": "Edit Stop Date", "Edit task": "Edit task", "Edit Task": "Edit Task", "Edit Tasks": "Edit Tasks", @@ -1110,30 +1078,25 @@ "Employer ½ FICA": "Employer ½ FICA", "Employment Status": "Employment Status", "Employment Status is a required field": "Employment Status is a required field", - "Employment type": "Employment type", - "Employment Type": "Employment Type", "Enable Calendar Integration": "Enable Calendar Integration", "Enable pivot": "Enable pivot", "Enabled Google Calendar Integration!": "Enabled Google Calendar Integration!", "End Date": "End Date", "End Date (Optional)": "End Date (Optional)", - "End date added successfully": "End date added successfully", - "End date must be at least one day after the transfer date": "End date must be at least one day after the transfer date", + "End date must be after transfer date": "End date must be after transfer date", "End date must be later than or equal to start date": "End date must be later than or equal to start date", - "End date updated successfully": "End date updated successfully", "End Date:": "End Date:", "End of Year Ask": "End of Year Ask", "Ending Balance: {{balance}}": "Ending Balance: {{balance}}", "ends with": "ends with", "Ends with": "Ends with", "Engaged": "Engaged", - "Enter amounts for the following categories of reimbursable and ministry expenses. The <2>Ministry Partner Giving Analysis tool can show you your averages in some of these categories. If you did not take full reimbursements for the entire year, or if your reimbursements were abnormally high (e.g. you had a surgery or bought a new computer), or low (e.g. no summer mission), you will want to adjust the averages from the MPGA to reflect an average year. Click the link above and look at the Ministry rows in the Expenses table.": "Enter amounts for the following categories of reimbursable and ministry expenses. The <2>Ministry Partner Giving Analysis tool can show you your averages in some of these categories. If you did not take full reimbursements for the entire year, or if your reimbursements were abnormally high (e.g. you had a surgery or bought a new computer), or low (e.g. no summer mission), you will want to adjust the averages from the MPGA to reflect an average year. Click the link above and look at the Ministry rows in the Expenses table.", + "Enter amounts for the following categories of reimbursable and ministry expenses. The <2>MPGA tool on StaffWeb can show you your averages in some of these categories. If you did not take full reimbursements for the entire year, or if your reimbursements were abnormally high (e.g. you had a surgery or bought a new computer), or low (e.g. no summer mission), you will want to adjust the averages from the MPGA to reflect an average year. Click the link above, go to the Income/Expenses tab, and look under the Ministry Expenses section.": "Enter amounts for the following categories of reimbursable and ministry expenses. The <2>MPGA tool on StaffWeb can show you your averages in some of these categories. If you did not take full reimbursements for the entire year, or if your reimbursements were abnormally high (e.g. you had a surgery or bought a new computer), or low (e.g. no summer mission), you will want to adjust the averages from the MPGA to reflect an average year. Click the link above, go to the Income/Expenses tab, and look under the Ministry Expenses section.", "Enter email address": "Enter email address", "Enter hourly rate": "Enter hourly rate", "Enter monthly benefits charge": "Enter monthly benefits charge", "Enter telephone number": "Enter telephone number", "Enter the greater of this amount or zero.": "Enter the greater of this amount or zero.", - "Enter the ministry expenses you are reimbursed for each year. Monthly entries are used as-is and annual entries are divided by 12; the combined total is included in your support goal.": "Enter the ministry expenses you are reimbursed for each year. Monthly entries are used as-is and annual entries are divided by 12; the combined total is included in your support goal.", "Enter yearly salary": "Enter yearly salary", "Enter your monthly budget": "Enter your monthly budget", "Entered in the previous section.": "Entered in the previous section.", @@ -1146,6 +1109,7 @@ "Eritrea": "Eritrea", "Error deleting email address {{email}}": "Error deleting email address {{email}}", "Error deleting phone number {{phoneNumber}}": "Error deleting phone number {{phoneNumber}}", + "Error saving changes": "Error saving changes", "Error updating {{name}}'s commitment info": "Error updating {{name}}'s commitment info", "Error updating contact {{name}}": "Error updating contact {{name}}", "Error updating contacts": "Error updating contacts", @@ -1189,8 +1153,6 @@ "Expenses": "Expenses", "Expenses Categories": "Expenses Categories", "Expenses:": "Expenses:", - "Expenses: ": "Expenses: ", - "Expenses: {{transfersOut}}": "Expenses: {{transfersOut}}", "Expired Referral": "Expired Referral", "Export": "Export", "Export {{number}} Selected": "Export {{number}} Selected", @@ -1212,9 +1174,9 @@ "Failed to add contact(s) to the appeal": "Failed to add contact(s) to the appeal", "Failed to add contacts to appeal": "Failed to add contacts to appeal", "Failed to add email address": "Failed to add email address", - "Failed to add end date": "Failed to add end date", "Failed to add entry. Please try again.": "Failed to add entry. Please try again.", "Failed to add phone number": "Failed to add phone number", + "Failed to add stop date": "Failed to add stop date", "Failed to confirm user group.": "Failed to confirm user group.", "Failed to create transfer": "Failed to create transfer", "Failed to delete entry. Please try again.": "Failed to delete entry. Please try again.", @@ -1224,17 +1186,15 @@ "Failed to save hours entry. Please try again.": "Failed to save hours entry. Please try again.", "Failed to update {{count}} duplicate(s)_one": "Failed to update {{count}} duplicate(s)", "Failed to update {{count}} duplicate(s)_other": "Failed to update {{count}} duplicate(s)", - "Failed to update end date": "Failed to update end date", + "Failed to update stop date": "Failed to update stop date", "Failed to update the appeal": "Failed to update the appeal", "Failed to update transfer": "Failed to update transfer", "Failed Transfers": "Failed Transfers", "Fair Rental Value": "Fair Rental Value", "Falkland Islands (Malvinas)": "Falkland Islands (Malvinas)", "false": "false", - "Family in a hotel/room": "Family in a hotel/room", "Family Size": "Family Size", "Family Size must be one of the options": "Family Size must be one of the options", - "Family Status": "Family Status", "Faroe Islands": "Faroe Islands", "Female": "Female", "Field": "Field", @@ -1254,7 +1214,7 @@ "Filter where {{column}} {{operator}} {{value}}": "Filter where {{column}} {{operator}} {{value}}", "Filter where {{column}} is any of: {{value}}": "Filter where {{column}} is any of: {{value}}", "Filters": "Filters", - "Final total goal including attrition rate applied to line 5.": "Final total goal including attrition rate applied to line 5.", + "Final total goal including attrition rate applied to line 6.": "Final total goal including attrition rate applied to line 6.", "Financial Information": "Financial Information", "Financial Partners": "Financial Partners", "Financial, Special, and Prayer partners that have an empty Newsletter Status appear here. Choose a newsletter status for the contacts below.": "Financial, Special, and Prayer partners that have an empty Newsletter Status appear here. Choose a newsletter status for the contacts below.", @@ -1268,7 +1228,6 @@ "First Name is required": "First Name is required", "First, connect your organization to your {{appName}} account.": "First, connect your organization to your {{appName}} account.", "First, filter by at least one organization that you administrate.": "First, filter by at least one organization that you administrate.", - "Fiscal Year Quarters": "Fiscal Year Quarters", "Fix Commitment Info": "Fix Commitment Info", "Fix Email Addresses": "Fix Email Addresses", "Fix Mailing Addresses": "Fix Mailing Addresses", @@ -1287,9 +1246,7 @@ "For Presenting Your Goal report": "For Presenting Your Goal report", "For the {{amount}} you are requesting, this will take {{approvalTimeframe}} as it needs to be signed off by {{approver}}.": "For the {{amount}} you are requesting, this will take {{approvalTimeframe}} as it needs to be signed off by {{approver}}.", "Foreign Amount": "Foreign Amount", - "Form Progress": "Form Progress", "Form Steps": "Form Steps", - "Form Type": "Form Type", "Form Unavailable": "Form Unavailable", "Fourteen month report table": "Fourteen month report table", "France": "France", @@ -1300,10 +1257,7 @@ "From": "From", "From Account": "From Account", "From account is required": "From account is required", - "From your Print settings, you may also save the page as a PDF to share digitally.": "From your Print settings, you may also save the page as a PDF to share digitally.", - "Full calculator with reimbursable expenses and 403b contributions.": "Full calculator with reimbursable expenses and 403b contributions.", "Full Names": "Full Names", - "Full time": "Full time", "Full-time": "Full-time", "Fund Transfer": "Fund Transfer", "Future Contacts": "Future Contacts", @@ -1366,7 +1320,6 @@ "Google Integration Overview": "Google Integration Overview", "Google Maps": "Google Maps", "Google’s suite of tools are great at connecting you to your\n Ministry Partners.": "Google’s suite of tools are great at connecting you to your\n Ministry Partners.", - "Great job completing the MPD Goal Calculation process!": "Great job completing the MPD Goal Calculation process!", "Great progress comes from great goals!": "Great progress comes from great goals!", "Greater than": "Greater than", "Greater than or equal to": "Greater than or equal to", @@ -1380,7 +1333,6 @@ "Gross Monthly Pay × {{rate}}": "Gross Monthly Pay × {{rate}}", "Gross Monthly Pay from Salary section × 403b Contribution Percentage from Setup section": "Gross Monthly Pay from Salary section × 403b Contribution Percentage from Setup section", "Gross Monthly Pay Subtotal + Reimbursable Expenses + 403b Contributions + Work Comp + Benefits": "Gross Monthly Pay Subtotal + Reimbursable Expenses + 403b Contributions + Work Comp + Benefits", - "Gross Monthly Pay Subtotal + Work Comp + Benefits": "Gross Monthly Pay Subtotal + Work Comp + Benefits", "Gross Monthly Salary": "Gross Monthly Salary", "Gross Requested Salary": "Gross Requested Salary", "Gross Salary": "Gross Salary", @@ -1406,6 +1358,7 @@ "Healthcare expenses that exceed the annual reimbursable limit": "Healthcare expenses that exceed the annual reimbursable limit", "Healthcare Reimbursement": "Healthcare Reimbursement", "Heard Island and Mcdonald Islands": "Heard Island and Mcdonald Islands", + "Help logo": "Help logo", "Hide": "Hide", "Hide announcement": "Hide announcement", "hide children": "hide children", @@ -1421,7 +1374,6 @@ "Hong Kong": "Hong Kong", "Hour To Send Notifications": "Hour To Send Notifications", "Hourly": "Hourly", - "Hourly Pay Rate": "Hourly Pay Rate", "Hours": "Hours", "Hours per Week": "Hours per Week", "Hours Per Week Calculator": "Hours Per Week Calculator", @@ -1431,19 +1383,15 @@ "Household Expenses": "Household Expenses", "Housing Allowances": "Housing Allowances", "Housing Down Payment": "Housing Down Payment", - "How much special needs support have you already received for NSO?": "How much special needs support have you already received for NSO?", "How the reminder will be sent": "How the reminder will be sent", "How would you like your Additional Salary Request contributed to your 403(b)?": "How would you like your Additional Salary Request contributed to your 403(b)?", "HR Tools": "HR Tools", "HR Tools - {{ title }}": "HR Tools - {{ title }}", + "HR Tools - Paid with Designation Support Goal Calculator": "HR Tools - Paid with Designation Support Goal Calculator", "HR Tools | Additional Salary Request": "HR Tools | Additional Salary Request", - "HR Tools | Goal Calculation": "HR Tools | Goal Calculation", - "HR Tools | MHA Calculation Tool": "HR Tools | MHA Calculation Tool", + "HR Tools | MHA Calculation Form": "HR Tools | MHA Calculation Form", "HR Tools | Ministry Partner Reminders": "HR Tools | Ministry Partner Reminders", "HR Tools | MPD Goal Calculator": "HR Tools | MPD Goal Calculator", - "HR Tools | MPD Supervisor Report": "HR Tools | MPD Supervisor Report", - "HR Tools | New Staff Goal Calculator": "HR Tools | New Staff Goal Calculator", - "HR Tools | NSO MPD Questionnaire": "HR Tools | NSO MPD Questionnaire", "HR Tools | Paid with Designation Support Goal Calculator": "HR Tools | Paid with Designation Support Goal Calculator", "HR Tools | Salary Calculation Form": "HR Tools | Salary Calculation Form", "HR Tools | Savings Fund Transfer": "HR Tools | Savings Fund Transfer", @@ -1454,7 +1402,6 @@ "I, the user, acknowledge that once I export my data, I have 30 days until my data will be deleted on {{appName}} servers.": "I, the user, acknowledge that once I export my data, I have 30 days until my data will be deleted on {{appName}} servers.", "i.e. marital, family or spiritual issues": "i.e. marital, family or spiritual issues", "i.e. short paychecks-no approval is needed so section B does not need to be filled out.": "i.e. short paychecks-no approval is needed so section B does not need to be filled out.", - "IBS and NSO": "IBS and NSO", "Iceland": "Iceland", "If blank you will not be reminded": "If blank you will not be reminded", "If checked, converted balence will be added to account overall balance": "If checked, converted balence will be added to account overall balance", @@ -1466,7 +1413,6 @@ "If this amount is greater than your CAP (the lesser of line 9a or 9b), it must be approved.": "If this amount is greater than your CAP (the lesser of line 9a or 9b), it must be approved.", "If this is correct, please confirm. If this is incorrect, please contact ": "If this is correct, please confirm. If this is incorrect, please contact ", "If this user has access to a ministry designation, then consider whether someone else in your organization needs this. If you want to retain the account, then share it with the appropriate user.": "If this user has access to a ministry designation, then consider whether someone else in your organization needs this. If you want to retain the account, then share it with the appropriate user.", - "If you are a parent with children in Childcare, please enter how many.": "If you are a parent with children in Childcare, please enter how many.", "If you are already logged in using your ministry account, you'll need to ": "If you are already logged in using your ministry account, you'll need to ", "If you are switching this contact away from Partner - Financial status, their commitment amount and frequency will no longer be included in calculations. Would you like to remove their commitment amount and frequency, as well?": "If you are switching this contact away from Partner - Financial status, their commitment amount and frequency will no longer be included in calculations. Would you like to remove their commitment amount and frequency, as well?", "If you are trying to share coaching access please click No below and try again through the Manage\n Coaches page in Settings.": "If you are trying to share coaching access please click No below and try again through the Manage\n Coaches page in Settings.", @@ -1510,23 +1456,16 @@ "In response to those requests, we've set up a new, self-service ": "In response to those requests, we've set up a new, self-service ", "In SECA": "In SECA", "In the popup box, choose the top button, \"Export Database to XML\".": "In the popup box, choose the top button, \"Export Database to XML\".", - "Inactive - No Payroll": "Inactive - No Payroll", - "Inactive - Payroll Eligible": "Inactive - Payroll Eligible", - "Inactive - Process when Earnings": "Inactive - Process when Earnings", "Includes group medical and dental coverage, life insurance, disability insurance, worker's compensation, and employer contribution to a 403(b) retirement plan.": "Includes group medical and dental coverage, life insurance, disability insurance, worker's compensation, and employer contribution to a 403(b) retirement plan.", "Includes tax on the social security": "Includes tax on the social security", "Income": "Income", "Income & Expenses Analysis": "Income & Expenses Analysis", "Income & Expenses Analysis: Last 12 Months": "Income & Expenses Analysis: Last 12 Months", "Income and Expenses": "Income and Expenses", - "Income and expenses are combined by categories by default. This may be useful for long date ranges (e.g., \"Year to Date\").\n Select which categories to keep consolidated.": "Income and expenses are combined by categories by default. This may be useful for long date ranges (e.g., \"Year to Date\").\n Select which categories to keep consolidated.", "Income and Expenses: {{timeTitle}}": "Income and Expenses: {{timeTitle}}", "Income Report": "Income Report", "Income:": "Income:", - "Income: ": "Income: ", - "Income: {{transfersIn}}": "Income: {{transfersIn}}", "Income/Expense Analysis": "Income/Expense Analysis", - "Incomplete": "Incomplete", "Increased Recently": "Increased Recently", "India": "India", "Individuals": "Individuals", @@ -1580,7 +1519,6 @@ "Is on or before": "Is on or before", "Is positive?": "Is positive?", "Is this your user group?": "Is this your user group?", - "Is your ministry assignment location within 50 miles of one of these cities?": "Is your ministry assignment location within 50 miles of one of these cities?", "Isle of Man": "Isle of Man", "Israel": "Israel", "It looks like this contact may have a duplicate.": "It looks like this contact may have a duplicate.", @@ -1632,7 +1570,6 @@ "Latest": "Latest", "Latvia": "Latvia", "Learn About Goalsetting": "Learn About Goalsetting", - "Learn About Goalsetting (opens in a new tab)": "Learn About Goalsetting (opens in a new tab)", "Least Likely": "Least Likely", "Leave empty to use full donation amount": "Leave empty to use full donation amount", "Leave Insurance": "Leave Insurance", @@ -1686,7 +1623,6 @@ "Loading": "Loading", "Loading donations graph": "Loading donations graph", "Loading...": "Loading...", - "Local / Commuting": "Local / Commuting", "Locale": "Locale", "Location": "Location", "Log": "Log", @@ -1779,7 +1715,7 @@ "MHA Amount Per Paycheck": "MHA Amount Per Paycheck", "MHA Approved by Board": "MHA Approved by Board", "MHA Available on": "MHA Available on", - "MHA Calculation Tool": "MHA Calculation Tool", + "MHA Calculation Form": "MHA Calculation Form", "MHA Claimed in Salary": "MHA Claimed in Salary", "MHA Edit Request": "MHA Edit Request", "MHA Eligibility": "MHA Eligibility", @@ -1798,22 +1734,17 @@ "Mileage Label": "Mileage Label", "min": "min", "Min": "Min", - "Minimum": "Minimum", "Minimum Required Salary": "Minimum Required Salary", "Minimum Salary": "Minimum Salary", "Minister's Housing Allowance": "Minister's Housing Allowance", - "Minister's Housing Allowance Calculation Tool": "Minister's Housing Allowance Calculation Tool", + "Minister's Housing Allowance Request": "Minister's Housing Allowance Request", "Minister's Housing Allowance Status": "Minister's Housing Allowance Status", "Ministry": "Ministry", "Ministry Benefits": "Ministry Benefits", "Ministry Cell Phone": "Ministry Cell Phone", - "Ministry Cell Phone (max {{max}}/mo)": "Ministry Cell Phone (max {{max}}/mo)", - "Ministry Cell Phone and Ministry Internet reimbursements are capped at the per-month maximums shown next to each field name. Amounts entered above the maximum will be saved as the maximum.": "Ministry Cell Phone and Ministry Internet reimbursements are capped at the per-month maximums shown next to each field name. Amounts entered above the maximum will be saved as the maximum.", "Ministry Expenses": "Ministry Expenses", "Ministry Expenses Subtotal": "Ministry Expenses Subtotal", - "Ministry Information": "Ministry Information", "Ministry Internet": "Ministry Internet", - "Ministry Internet (max {{max}}/mo)": "Ministry Internet (max {{max}}/mo)", "Ministry Miles": "Ministry Miles", "Ministry Partner": "Ministry Partner", "Ministry Partner Giving Analysis": "Ministry Partner Giving Analysis", @@ -1839,7 +1770,7 @@ "Monthly": "Monthly", "Monthly Average": "Monthly Average", "Monthly Base": "Monthly Base", - "Monthly Base × Geographic Multiplier ({{rate}})": "Monthly Base × Geographic Multiplier ({{rate}})", + "Monthly Base × (1 + Geographic Multiplier)": "Monthly Base × (1 + Geographic Multiplier)", "Monthly Budget": "Monthly Budget", "Monthly Commitment Average: ": "Monthly Commitment Average: ", "Monthly Commitment Goal: ": "Monthly Commitment Goal: ", @@ -1856,7 +1787,6 @@ "Monthly Support Gained": "Monthly Support Gained", "Monthly Support Lost": "Monthly Support Lost", "Monthly Support Needs": "Monthly Support Needs", - "Monthly Support Needs Chart": "Monthly Support Needs Chart", "Monthly Support to be Developed": "Monthly Support to be Developed", "Monthly value for furniture, appliances, decorations, and cleaning.": "Monthly value for furniture, appliances, decorations, and cleaning.", "Montserrat": "Montserrat", @@ -1881,12 +1811,10 @@ "MPD info not set up on account list": "MPD info not set up on account list", "MPD Miscellaneous": "MPD Miscellaneous", "MPD Newsletter": "MPD Newsletter", - "MPD Supervisor Report": "MPD Supervisor Report", "MPDX Tools": "MPDX Tools", "MPDX Tools - {{ title }}": "MPDX Tools - {{ title }}", - "MPGA": "MPGA", - "MPGA Report": "MPGA Report", "Must be a number": "Must be a number", + "Must be greater than $0.": "Must be greater than $0.", "Must complete an MHI form instead": "Must complete an MHI form instead", "Must have at least 1 Phone Number to confirm": "Must have at least 1 Phone Number to confirm", "Must use a positive number for amount": "Must use a positive number for amount", @@ -1904,9 +1832,7 @@ "Namibia": "Namibia", "Nauru": "Nauru", "Nearest Geographic Multiplier Location": "Nearest Geographic Multiplier Location", - "Need to update the amount you're transferring each month? No problem! Just set an end date for the end of the current month on your existing transfer. After that, go ahead and set up a brand-new monthly transfer with the updated amount.": "Need to update the amount you're transferring each month? No problem! Just set an end date for the end of the current month on your existing transfer. After that, go ahead and set up a brand-new monthly transfer with the updated amount.", - "needs attention": "needs attention", - "Negative last month": "Negative last month", + "Need to update the amount you're transferring each month? No problem! Just set a stop date for the end of the current month on your existing transfer. After that, go ahead and set up a brand-new monthly transfer with the updated amount.": "Need to update the amount you're transferring each month? No problem! Just set a stop date for the end of the current month on your existing transfer. After that, go ahead and set up a brand-new monthly transfer with the updated amount.", "Nepal": "Nepal", "Net Additional Salary": "Net Additional Salary", "Net Additional Salary (Before Taxes)": "Net Additional Salary (Before Taxes)", @@ -1936,7 +1862,6 @@ "New Salary Calculation Summary": "New Salary Calculation Summary", "New Social": "New Social", "New Staff Goal": "New Staff Goal", - "New Staff Goal Calculator": "New Staff Goal Calculator", "New Zealand": "New Zealand", "Newsletter": "Newsletter", "Newsletter Dialog": "Newsletter Dialog", @@ -1949,7 +1874,6 @@ "Next Increase Ask": "Next Increase Ask", "Next Month": "Next Month", "Next Step": "Next Step", - "Next Steps": "Next Steps", "Nicaragua": "Nicaragua", "Niger": "Niger", "Nigeria": "Nigeria", @@ -1963,6 +1887,7 @@ "No Appeals have been setup yet.": "No Appeals have been setup yet.", "No call logged in the past year": "No call logged in the past year", "No celebrations to show.": "No celebrations to show.", + "No changes have been made": "No changes have been made", "No Coaches": "No Coaches", "No columns": "No columns", "No comments to show": "No comments to show", @@ -2012,7 +1937,6 @@ "No prompt history": "No prompt history", "No results found.": "No results found.", "No rows": "No rows", - "No staff members found": "No staff members found", "No Status": "No Status", "No Summary Report Available": "No Summary Report Available", "No tags added in last 6 {{period}}.": "No tags added in last 6 {{period}}.", @@ -2037,7 +1961,7 @@ "Not taxed now": "Not taxed now", "Not Yet Enrolled": "Not Yet Enrolled", "Note": "Note", - "Note is required": "Note is required", + "Note (Optional)": "Note (Optional)", "Note: Italy staff must complete a paper MHI form.": "Note: Italy staff must complete a paper MHI form.", "Note: You may be enrolled in our automatic annual 1% increase to your 403(b). To verify this or update it, you can go to <2>your Principal account. Your gross salary will not be affected, so your net pay will decrease by 1%. If you would like to receive the same net pay after this 1% increase, you will need to calculate a new salary here.": "Note: You may be enrolled in our automatic annual 1% increase to your 403(b). To verify this or update it, you can go to <2>your Principal account. Your gross salary will not be affected, so your net pay will decrease by 1%. If you would like to receive the same net pay after this 1% increase, you will need to calculate a new salary here.", "Notes": "Notes", @@ -2046,12 +1970,7 @@ "Notifications ({{unread}} unread)": "Notifications ({{unread}} unread)", "Notifications table": "Notifications table", "Notifications updated successfully": "Notifications updated successfully", - "Now that you've reviewed your goal, you can share your Support Needs Presentation by printing the presentation below.": "Now that you've reviewed your goal, you can share your Support Needs Presentation by printing the presentation below.", "NS Reference": "NS Reference", - "NSO": "NSO", - "NSO Information": "NSO Information", - "NSO MPD Questionnaire": "NSO MPD Questionnaire", - "NSO/IBS Tuition, housing, food, travel, MPD Refresh Retreat, Faith & Finance Course.": "NSO/IBS Tuition, housing, food, travel, MPD Refresh Retreat, Faith & Finance Course.", "number": "number", "number after {{boardDateFormatted}}": "number after {{boardDateFormatted}}", "Number of Ministry Partners: ": "Number of Ministry Partners: ", @@ -2070,7 +1989,6 @@ "On Hand ({{totalCount}})": "On Hand ({{totalCount}})", "On smaller screens, some columns may be hidden. Please scroll\n horizontally to view all the data.": "On smaller screens, some columns may be hidden. Please scroll\n horizontally to view all the data.", "On time": "On time", - "on track": "on track", "On your digital newsletter list but has no people with a valid email address": "On your digital newsletter list but has no people with a valid email address", "On your physical newsletter list but has no mailing address": "On your physical newsletter list but has no mailing address", "Once approved, when you calculate your salary, you will see the approved amount that can be applied to your salary. If you believe this is incorrect, or would like to complete the required IBS courses, please contact Personnel Records at <2>(407) 826-2230 or <5>MHA@cru.org.": "Once approved, when you calculate your salary, you will see the approved amount that can be applied to your salary. If you believe this is incorrect, or would like to complete the required IBS courses, please contact Personnel Records at <2>(407) 826-2230 or <5>MHA@cru.org.", @@ -2078,7 +1996,6 @@ "Once the export is completed, we will send you an email with a link to download your export.": "Once the export is completed, we will send you an email with a link to download your export.", "Once this is done you'll need to wait 24 hours for {{appName}} to sync your data.": "Once this is done you'll need to wait 24 hours for {{appName}} to sync your data.", "One of the great features of {{appName}} is its ability to bring in information and contacts from other places you might have used in the past.\nYou can import from software like TntConnect, Google Contacts or a Spreadsheet.": "One of the great features of {{appName}} is its ability to bring in information and contacts from other places you might have used in the past.\nYou can import from software like TntConnect, Google Contacts or a Spreadsheet.", - "One of the most common causes of errors in MPD goals is from people claiming too many years on staff. The system will always show 0 unless you have already been through an NSO training and are on staff or have been previously. Intern, STINT, and Part-time years <2>do not count towards this number.": "One of the most common causes of errors in MPD goals is from people claiming too many years on staff. The system will always show 0 unless you have already been through an NSO training and are on staff or have been previously. Intern, STINT, and Part-time years <2>do not count towards this number.", "One Time": "One Time", "Online Reminder System": "Online Reminder System", "Only delete if you know that this user will not be returning to any other missional organization that uses {{appName}}. You may need to confirm this with them.": "Only delete if you know that this user will not be returning to any other missional organization that uses {{appName}}. You may need to confirm this with them.", @@ -2109,7 +2026,6 @@ "Other Leave": "Other Leave", "Other Monthly Reimbursements": "Other Monthly Reimbursements", "Other Standard Earnings": "Other Standard Earnings", - "Other Subtotal": "Other Subtotal", "Our apologies. It appears something has gone wrong. Please try again later and contact the administrator if this problem persists.": "Our apologies. It appears something has gone wrong. Please try again later and contact the administrator if this problem persists.", "Our records indicate that you have an MHA request <2>waiting to be processed. To view your MHA request, click on the \"View Current MHA\" button below.": "Our records indicate that you have an MHA request <2>waiting to be processed. To view your MHA request, click on the \"View Current MHA\" button below.", "Our records show that not all staff have a Minister's Housing Allowance for the effective date of this salary calculation. If an MHA Request form has not yet been submitted, it may be completed using <2>this link. Pending MHA Requests will not apply to this salary calculation but a new Salary Calculation Form can be submitted after approval.": "Our records show that not all staff have a Minister's Housing Allowance for the effective date of this salary calculation. If an MHA Request form has not yet been submitted, it may be completed using <2>this link. Pending MHA Requests will not apply to this salary calculation but a new Salary Calculation Form can be submitted after approval.", @@ -2129,7 +2045,6 @@ "Override paycheck amount": "Override paycheck amount", "Overtime Pay": "Overtime Pay", "Own": "Own", - "PA Card": "PA Card", "Paid Time Off": "Paid Time Off", "Paid with Designation Support Goal Calculator": "Paid with Designation Support Goal Calculator", "Pakistan": "Pakistan", @@ -2139,7 +2054,6 @@ "Papua New Guinea": "Papua New Guinea", "Paraguay": "Paraguay", "Parental/Family Leave": "Parental/Family Leave", - "Part time": "Part time", "Part-time": "Part-time", "Partner": "Partner", "Partner - Financial": "Partner - Financial", @@ -2168,11 +2082,8 @@ "Pay Type": "Pay Type", "Pay Type is a required field": "Pay Type is a required field", "Payee": "Payee", - "Payroll": "Payroll", "Payroll Taxes": "Payroll Taxes", "PDF of Mail Merged Labels": "PDF of Mail Merged Labels", - "PDS Goal Summary": "PDS Goal Summary", - "Pending - No Payroll": "Pending - No Payroll", "Pending Invites": "Pending Invites", "Pending Request": "Pending Request", "Pending Salary Calculation Form": "Pending Salary Calculation Form", @@ -2182,8 +2093,6 @@ "People with new phone numbers or multiple primary phone numbers will appear here.": "People with new phone numbers or multiple primary phone numbers will appear here.", "People with similar names and partner account numbers will appear here.": "People with similar names and partner account numbers will appear here.", "People with similar names will appear here.": "People with similar names will appear here.", - "per hour": "per hour", - "per year": "per year", "Percentage breakdown of taxes, SECA, VTL and other deductions.": "Percentage breakdown of taxes, SECA, VTL and other deductions.", "Percentage of salary contributed to Roth 403(b) retirement account.": "Percentage of salary contributed to Roth 403(b) retirement account.", "Percentage of salary contributed to Traditional 403(b) retirement account.": "Percentage of salary contributed to Traditional 403(b) retirement account.", @@ -2195,10 +2104,8 @@ "person": "person", "Person created successfully": "Person created successfully", "Person deleted successfully": "Person deleted successfully", - "Person Number": "Person Number", - "Person number information": "Person number information", - "Person Number: {{personNumbers}}": "Person Number: {{personNumbers}}", - "Person Numbers: {{personNumbers}}": "Person Numbers: {{personNumbers}}", + "Person Number: {{personNumbers}}_one": "Person Number: {{personNumbers}}", + "Person Number: {{personNumbers}}_other": "Person Numbers: {{personNumbers}}", "Person updated successfully": "Person updated successfully", "Personal": "Personal", "Personal Contact Information": "Personal Contact Information", @@ -2233,19 +2140,11 @@ "Please complete the Approval Process section below and we will review your request through our <2>Progressive Approvals process. Please note:": "Please complete the Approval Process section below and we will review your request through our <2>Progressive Approvals process. Please note:", "Please consider submitting your request at your maximum allowable salary to reduce the amount on {spouseName}'s request, which may avoid requiring approval through our <4>Progressive Approvals process.": "Please consider submitting your request at your maximum allowable salary to reduce the amount on {spouseName}'s request, which may avoid requiring approval through our <4>Progressive Approvals process.", "Please ensure you've read the above before continuing.": "Please ensure you've read the above before continuing.", - "Please enter 0 if this does not apply to you.": "Please enter 0 if this does not apply to you.", "Please enter a goal": "Please enter a goal", "Please enter a name": "Please enter a name", - "Please enter a number, or 0 if you have none.": "Please enter a number, or 0 if you have none.", - "Please enter a positive amount.": "Please enter a positive amount.", - "Please enter a positive number.": "Please enter a positive number.", "Please enter a valid email address": "Please enter a valid email address", "Please enter a valid phone number": "Please enter a valid phone number", "Please enter a value for all required fields.": "Please enter a value for all required fields.", - "Please enter a whole dollar amount.": "Please enter a whole dollar amount.", - "Please enter a whole number.": "Please enter a whole number.", - "Please enter an amount, or 0 if you have none.": "Please enter an amount, or 0 if you have none.", - "Please enter an assignment location": "Please enter an assignment location", "Please enter dollar amounts for each category below to calculate your Annual MHA. The board will review this {{after}} and you will receive notice of your {{approval}}.": "Please enter dollar amounts for each category below to calculate your Annual MHA. The board will review this {{after}} and you will receive notice of your {{approval}}.", "Please enter the amount of your salary you would like to request as {{sectionKind}} below. If you have a pending {{sectionKind}} Request for a new amount, it will not apply to this salary calculation but you can submit a new Salary Calculation Form after it is approved.": "Please enter the amount of your salary you would like to request as {{sectionKind}} below. If you have a pending {{sectionKind}} Request for a new amount, it will not apply to this salary calculation but you can submit a new Salary Calculation Form after it is approved.", "Please enter the desired dollar amounts for the appropriate categories and review totals before submitting. Your Net Additional Salary calculated below represents the amount you will receive as an additional salary check (before taxes) and is equal to the amount you are requesting minus any amount being contributed to your 403(b).": "Please enter the desired dollar amounts for the appropriate categories and review totals before submitting. Your Net Additional Salary calculated below represents the amount you will receive as an additional salary check (before taxes) and is equal to the amount you are requesting minus any amount being contributed to your 403(b).", @@ -2255,17 +2154,11 @@ "Please explain the reason for deleting this user.": "Please explain the reason for deleting this user.", "Please make adjustments to your request to continue.": "Please make adjustments to your request to continue.", "Please make adjustments to your request to continue. You may make a separate request up to {spouseName}'s maximum allowable salary if desired. After using both you and {spouseName}'s maximum allowable salary, any additional requests can be submitted online but will require approval through our <6>Progressive Approvals process.": "Please make adjustments to your request to continue. You may make a separate request up to {spouseName}'s maximum allowable salary if desired. After using both you and {spouseName}'s maximum allowable salary, any additional requests can be submitted online but will require approval through our <6>Progressive Approvals process.", - "Please provide your cell phone number.": "Please provide your cell phone number.", - "Please read all information before answering.": "Please read all information before answering.", "Please review and update your information below. Click \"Continue\" to request your new salary.": "Please review and update your information below. Click \"Continue\" to request your new salary.", "Please review the Annual MHA Request that you have submitted for Board approval and make any changes necessary here. The board will review this {{after}} and you will receive notice of your {{approval}}.": "Please review the Annual MHA Request that you have submitted for Board approval and make any changes necessary here. The board will review this {{after}} and you will receive notice of your {{approval}}.", "Please review the detailed summary of the salary you are requesting below. If it is correct, click the \"Submit\" button on the bottom of this page. You may make changes to some of the fields by selecting the section you would like to return to in the menu to the left.": "Please review the detailed summary of the salary you are requesting below. If it is correct, click the \"Submit\" button on the bottom of this page. You may make changes to some of the fields by selecting the section you would like to return to in the menu to the left.", "Please review your current contribution elections below. If you would like to make changes you may do so by logging into your <2>Principal account. Please wait to complete your Salary Calculation Form until those changes are reflected here.": "Please review your current contribution elections below. If you would like to make changes you may do so by logging into your <2>Principal account. Please wait to complete your Salary Calculation Form until those changes are reflected here.", "Please select a file to upload.": "Please select a file to upload.", - "Please select a ministry": "Please select a ministry", - "Please select an answer.": "Please select an answer.", - "Please select an answer. If none of the cities apply, select \"None.\"": "Please select an answer. If none of the cities apply, select \"None.\"", - "Please select an assignment type": "Please select an assignment type", "Please select an effective date": "Please select an effective date", "Please select how you would like to contribute to your 403(b).": "Please select how you would like to contribute to your 403(b).", "Please select how you would like to distribute your combined Maximum Allowable Salary between you and {{spouse}}:": "Please select how you would like to distribute your combined Maximum Allowable Salary between you and {{spouse}}:", @@ -2274,7 +2167,6 @@ "Please select the option that applies to you.": "Please select the option that applies to you.", "Please take a look at these sample rows that show how your CSV will import into {{appName}}. If you would like to make changes, go back to Step 2 or back to Step 1 to reimport all over again.": "Please take a look at these sample rows that show how your CSV will import into {{appName}}. If you would like to make changes, go back to Step 2 or back to Step 1 to reimport all over again.", "Please use the form below to make adjustments to your salary.": "Please use the form below to make adjustments to your salary.", - "Plus": "Plus", "Poland": "Poland", "Portugal": "Portugal", "Possible": "Possible", @@ -2284,7 +2176,6 @@ "prayerletters.com": "prayerletters.com", "prayerletters.com is a significant way to save valuable ministry\n time while more effectively connecting with your partners. Keep your\n physical newsletter list up to date in {{appName}} and then sync it to your\n prayerletters.com account with this integration.": "prayerletters.com is a significant way to save valuable ministry\n time while more effectively connecting with your partners. Keep your\n physical newsletter list up to date in {{appName}} and then sync it to your\n prayerletters.com account with this integration.", "PrayerLetters.com Overview": "PrayerLetters.com Overview", - "Pre-filled with the maximum allowed amount. Edit to a lower value if needed.": "Pre-filled with the maximum allowed amount. Edit to a lower value if needed.", "Predefined Filters": "Predefined Filters", "Preferences": "Preferences", "Preferences - {{ title }}": "Preferences - {{ title }}", @@ -2308,7 +2199,6 @@ "Primary Organization": "Primary Organization", "Primary Person": "Primary Person", "Print": "Print", - "Print Support Needs Presentation": "Print Support Needs Presentation", "Privacy Policy": "Privacy Policy", "Proceed to the next section. Your progress is automatically saved as you go.": "Proceed to the next section. Your progress is automatically saved as you go.", "Processing…": "Processing…", @@ -2317,15 +2207,8 @@ "Progress": "Progress", "Prompt": "Prompt", "Puerto Rico": "Puerto Rico", - "Purchase": "Purchase", "Qatar": "Qatar", "Quarterly": "Quarterly", - "Questionnaire Step 1": "Questionnaire Step 1", - "Questionnaire Step 2": "Questionnaire Step 2", - "Questionnaire Step 3": "Questionnaire Step 3", - "Questionnaire Step 4": "Questionnaire Step 4", - "Raising Initial Support - No Payroll": "Raising Initial Support - No Payroll", - "Raising Initial Support - Payroll Eligible": "Raising Initial Support - Payroll Eligible", "Reason": "Reason", "Reason / HelpScout Ticket Link": "Reason / HelpScout Ticket Link", "Receipt": "Receipt", @@ -2343,11 +2226,9 @@ "Refresh Google Account": "Refresh Google Account", "Refresh prayerletters.com Account": "Refresh prayerletters.com Account", "Region": "Region", - "Registration": "Registration", "Regular Giving": "Regular Giving", "Regular Pay": "Regular Pay", "Reimbursable Expenses": "Reimbursable Expenses", - "Reimbursable expenses have a {{floor}} per month minimum. If the sum of your monthly entries (plus annual entries divided by 12) falls below {{floor}}, the {{floor}} minimum is used in your support goal instead.": "Reimbursable expenses have a {{floor}} per month minimum. If the sum of your monthly entries (plus annual entries divided by 12) falls below {{floor}}, the {{floor}} minimum is used in your support goal instead.", "Reimbursable expenses that were not approved within 90 days": "Reimbursable expenses that were not approved within 90 days", "Reimbursable Medical Expenses": "Reimbursable Medical Expenses", "Relationship Code": "Relationship Code", @@ -2367,8 +2248,6 @@ "Remove all": "Remove all", "Remove Coach": "Remove Coach", "Remove Commitment": "Remove Commitment", - "Remove Connection": "Remove Connection", - "Remove Connection {{name}}": "Remove Connection {{name}}", "Remove Contact": "Remove Contact", "Remove donation confirmation": "Remove donation confirmation", "Remove star": "Remove star", @@ -2389,6 +2268,7 @@ "Report Settings": "Report Settings", "Reports": "Reports", "Reports - {{ title }}": "Reports - {{ title }}", + "Reports - Goal Calculation": "Reports - Goal Calculation", "Reports - Responsibility Centers": "Reports - Responsibility Centers", "Reports | Designation Accounts": "Reports | Designation Accounts", "Reports | Partner": "Reports | Partner", @@ -2414,6 +2294,7 @@ "Requested salary must be at least {{min}}": "Requested salary must be at least {{min}}", "Requests exceeding your Maximum Allowable Salary require additional review.": "Requests exceeding your Maximum Allowable Salary require additional review.", "Required": "Required", + "Required field.": "Required field.", "Research Abandoned": "Research Abandoned", "Research Contact Info": "Research Contact Info", "Reset": "Reset", @@ -2432,7 +2313,6 @@ "Retirement Contributions": "Retirement Contributions", "Retrieved from Principal. A combined percentage of your current tax deferred and Roth contributions.": "Retrieved from Principal. A combined percentage of your current tax deferred and Roth contributions.", "Retroactive Pay": "Retroactive Pay", - "Returned to Setup because the current step is no longer available.": "Returned to Setup because the current step is no longer available.", "Reunion": "Reunion", "Reverse Filter": "Reverse Filter", "Review and merge duplicate contacts": "Review and merge duplicate contacts", @@ -2441,8 +2321,6 @@ "Review Excluded": "Review Excluded", "Review spouse financial details and settings here.": "Review spouse financial details and settings here.", "Review spouse personal details and preferences here.": "Review spouse personal details and preferences here.", - "Review Your Calculation": "Review Your Calculation", - "Review your complete PDS goal and current support progress. Use this summary to share your goal and track your fundraising.": "Review your complete PDS goal and current support progress. Use this summary to share your goal and track your fundraising.", "Review your financial details and settings here.": "Review your financial details and settings here.", "Review your personal details and preferences here.": "Review your personal details and preferences here.", "Right Wins the Merge": "Right Wins the Merge", @@ -2456,7 +2334,6 @@ "Roth 403(b) Contribution %": "Roth 403(b) Contribution %", "Roth 403(b) Contributions": "Roth 403(b) Contributions", "Roth 403(b) Deduction": "Roth 403(b) Deduction", - "Round to the nearest dollar. Please enter 0 if you have none.": "Round to the nearest dollar. Please enter 0 if you have none.", "Row reordering": "Row reordering", "Rows": "Rows", "Rows ({{count}})_one": "Rows ({{count}})", @@ -2480,11 +2357,11 @@ "Salary Calculation": "Salary Calculation", "Salary Calculation Form": "Salary Calculation Form", "Salary Calculation Summary": "Salary Calculation Summary", + "Salary Calculator": "Salary Calculator", "Salary Calculator Sections": "Salary Calculator Sections", "Salary Cap Calculation": "Salary Cap Calculation", "Salary Currency": "Salary Currency", "Salary Other": "Salary Other", - "Salary Subtotal": "Salary Subtotal", "Samoa": "Samoa", "San Marino": "San Marino", "Sao Tome and Principe": "Sao Tome and Principe", @@ -2515,7 +2392,6 @@ "Search by subject, tags, contact name, or comments": "Search by subject, tags, contact name, or comments", "Search Contacts": "Search Contacts", "Search fields": "Search fields", - "Search name": "Search name", "Search Tasks": "Search Tasks", "Search…": "Search…", "Seasonal": "Seasonal", @@ -2531,9 +2407,6 @@ "Select": "Select", "Select {{count}} contact_one": "Select {{count}} contact", "Select {{count}} contact_other": "Select {{count}} contact", - "Select a city": "Select a city", - "Select a form type": "Select a form type", - "Select a ministry": "Select a ministry", "select all": "select all", "Select all {{count}} contacts_one": "Select all {{count}} contacts", "Select all {{count}} contacts_other": "Select all {{count}} contacts", @@ -2568,7 +2441,6 @@ "Senegal": "Senegal", "Separated": "Separated", "Serbia and Montenegro": "Serbia and Montenegro", - "Series {{number}}": "Series {{number}}", "Service Awards": "Service Awards", "Set as Primary": "Set as Primary", "Set default account": "Set default account", @@ -2590,7 +2462,6 @@ "Share Account": "Share Account", "Share this ministry account with other team members": "Share this ministry account with other team members", "Share this organization with other team members": "Share this organization with other team members", - "Sharing 2 in hotel/dorm room": "Sharing 2 in hotel/dorm room", "Short Term Assignment": "Short Term Assignment", "Show {{amount}} More": "Show {{amount}} More", "Show {{partnerCount}} Partners": "Show {{partnerCount}} Partners", @@ -2605,8 +2476,6 @@ "Show/Hide All": "Show/Hide All", "Showing {{count}}_one": "Showing {{count}}", "Showing {{count}}_other": "Showing {{count}}", - "Showing {{count}} of {{total}} · sorted by MPD health_one": "Showing {{count}} of {{total}} · sorted by MPD health", - "Showing {{count}} of {{total}} · sorted by MPD health_other": "Showing {{count}} of {{total}} · sorted by MPD health", "Showing {{value}} of {{total}}": "Showing {{value}} of {{total}}", "Showing {{value}} of {{value}}": "Showing {{value}} of {{value}}", "Sick Leave": "Sick Leave", @@ -2615,8 +2484,6 @@ "Sign In": "Sign In", "Sign In with {{authProviderName}}": "Sign In with {{authProviderName}}", "Sign Out": "Sign Out", - "Simple": "Simple", - "Simple excludes them; existing entries are preserved and will count again if you switch back.": "Simple excludes them; existing entries are preserved and will count again if you switch back.", "Since all columns have been removed, resetting columns to their default values": "Since all columns have been removed, resetting columns to their default values", "Since Campus Crusade is a non-profit organization, staff members are responsible for paying the entire amount of Social Security.": "Since Campus Crusade is a non-profit organization, staff members are responsible for paying the entire amount of Social Security.", "Since you are requesting above {{spouse}}'s and your combined Maximum Allowable Salary, you will need to provide the information below.": "Since you are requesting above {{spouse}}'s and your combined Maximum Allowable Salary, you will need to provide the information below.", @@ -2624,7 +2491,6 @@ "Since your combined request is still within your combined Max Allowable Salary, no additional approvals are required.": "Since your combined request is still within your combined Max Allowable Salary, no additional approvals are required.", "Singapore": "Singapore", "Single": "Single", - "Single in hotel/dorm room": "Single in hotel/dorm room", "size": "size", "Skip Step": "Skip Step", "Slovakia": "Slovakia", @@ -2635,7 +2501,6 @@ "Solomon Islands": "Solomon Islands", "Somalia": "Somalia", "Some of the contact(s) you have selected to add to this appeal are currently excluded. You will not be able to exclude these contacts once you add them to this appeal. Instead, you will be able to remove them from it.": "Some of the contact(s) you have selected to add to this appeal are currently excluded. You will not be able to exclude these contacts once you add them to this appeal. Instead, you will be able to remove them from it.", - "Some tips for printing:": "Some tips for printing:", "Something went wrong while loading your account information. Please try again later. If the problem persists, please contact {link}.": "Something went wrong while loading your account information. Please try again later. If the problem persists, please contact {link}.", "Sort": "Sort", "Sort By": "Sort By", @@ -2649,8 +2514,6 @@ "Spain": "Spain", "Special Gift": "Special Gift", "Special Gift Partners": "Special Gift Partners", - "Special Needs": "Special Needs", - "Special Needs Chart": "Special Needs Chart", "Special Needs Gained": "Special Needs Gained", "Special Pay": "Special Pay", "Speech recognition is not supported in this browser": "Speech recognition is not supported in this browser", @@ -2658,7 +2521,6 @@ "Spouse Age": "Spouse Age", "Spouse Age must be one of the options": "Spouse Age must be one of the options", "Spouse First Name": "Spouse First Name", - "Spouse Information": "Spouse Information", "Spouse Maximum Allowable Salary": "Spouse Maximum Allowable Salary", "Spouse MHA Amount Per Paycheck": "Spouse MHA Amount Per Paycheck", "Spouse Net Paycheck Amount": "Spouse Net Paycheck Amount", @@ -2674,22 +2536,15 @@ "Spouse's Personal Information": "Spouse's Personal Information", "Sri Lanka": "Sri Lanka", "Staff Account Number": "Staff Account Number", - "Staff Account Number: {{number}}": "Staff Account Number: {{number}}", "Staff Account Number: {{personNumber}}": "Staff Account Number: {{personNumber}}", "Staff Assessment": "Staff Assessment", - "Staff Conference Savings Account": "Staff Conference Savings Account", - "Staff details": "Staff details", - "Staff Expense": "Staff Expense", "Staff Expense Report": "Staff Expense Report", "Staff IDs:": "Staff IDs:", "Staff Info Summary": "Staff Info Summary", - "Staff Information": "Staff Information", "Staff members are eligible to contribute to a voluntary retirement program each month.": "Staff members are eligible to contribute to a voluntary retirement program each month.", "Staff Savings Fund": "Staff Savings Fund", "Staff Savings Fund Transfers": "Staff Savings Fund Transfers", - "Staff Status": "Staff Status", "Staff You Coach": "Staff You Coach", - "StaffCard": "StaffCard", "Standard": "Standard", "Star": "Star", "Starred": "Starred", @@ -2712,7 +2567,9 @@ "Step 2": "Step 2", "Step 3": "Step 3", "Step 4": "Step 4", - "Steps": "Steps", + "Stop Date": "Stop Date", + "Stop date added successfully": "Stop date added successfully", + "Stop date updated successfully": "Stop date updated successfully", "Stop grouping by {{name}}": "Stop grouping by {{name}}", "Stop Impersonating": "Stop Impersonating", "Stop recording": "Stop recording", @@ -2720,11 +2577,9 @@ "Stopped Giving": "Stopped Giving", "Stopped Giving Range": "Stopped Giving Range", "Stopping impersonation and redirecting you to the login page": "Stopping impersonation and redirecting you to the login page", - "Streamlined calculator without reimbursable expenses or 403b contributions.": "Streamlined calculator without reimbursable expenses or 403b contributions.", "Street": "Street", "Street Address": "Street Address", "Street is required to import any address information.": "Street is required to import any address information.", - "student loan debt": "student loan debt", "Subject": "Subject", "Subject is required": "Subject is required", "Subject to FICA": "Subject to FICA", @@ -2734,7 +2589,7 @@ "Submit For Approval": "Submit For Approval", "Submitting...": "Submitting...", "Subtotal": "Subtotal", - "Subtotal × {{rate}}%": "Subtotal × {{rate}}%", + "Subtotal × {{rate}}": "Subtotal × {{rate}}", "Subtotal Annual": "Subtotal Annual", "Subtotal including the administrative charge percentage.": "Subtotal including the administrative charge percentage.", "Subtotal Monthly": "Subtotal Monthly", @@ -2777,15 +2632,14 @@ "Summary Report": "Summary Report", "Summary Report - Responsibility Centers": "Summary Report - Responsibility Centers", "Summer Assignment Expenses": "Summer Assignment Expenses", - "Summer Mission": "Summer Mission", "Supplies and Materials": "Supplies and Materials", "Support Goal Percentage Progress": "Support Goal Percentage Progress", "Support Item": "Support Item", - "Support Items": "Support Items", "Support raising, Staff Conference, etc.": "Support raising, Staff Conference, etc.", "Suriname": "Suriname", "Surplus/Deficit": "Surplus/Deficit", "Svalbard and Jan Mayen": "Svalbard and Jan Mayen", + "Swap": "Swap", "Swaziland": "Swaziland", "Sweden": "Sweden", "Sweet! You're connected.": "Sweet! You're connected.", @@ -2815,7 +2669,6 @@ "Tags: ": "Tags: ", "Taiwan, Province of China": "Taiwan, Province of China", "Tajikistan": "Tajikistan", - "Take a moment to verify the staff information we have on record. If something is incorrect, please inform your MPD coordinator during New Staff Orientation and they will make a correction.": "Take a moment to verify the staff information we have on record. If something is incorrect, please inform your MPD coordinator during New Staff Orientation and they will make a correction.", "Take a moment to verify you and your spouse's information.": "Take a moment to verify you and your spouse's information.", "Take a moment to verify your information.": "Take a moment to verify your information.", "Tanzania, United Republic of": "Tanzania, United Republic of", @@ -2847,11 +2700,7 @@ "Taxes": "Taxes", "Taxes, SECA, VTL, etc.": "Taxes, SECA, VTL, etc.", "Taxes, SECA, VTL, etc. %": "Taxes, SECA, VTL, etc. %", - "Team": "Team", "Telephone Number": "Telephone Number", - "Tell us about the ministry assignment and location you expect to have.": "Tell us about the ministry assignment and location you expect to have.", - "Tell us about your financial situation.": "Tell us about your financial situation.", - "Tell us about your lodging while attending New Staff Orientation.": "Tell us about your lodging while attending New Staff Orientation.", "Template": "Template", "Temporary": "Temporary", "Tenure": "Tenure", @@ -2869,7 +2718,6 @@ "The contact's status has been updated. Now you can log the task that motivated this change.": "The contact's status has been updated. Now you can log the task that motivated this change.", "The deadline to make changes to this request was {{date}}. Please contact support if you need further assistance.": "The deadline to make changes to this request was {{date}}. Please contact support if you need further assistance.", "The earliest effective date will be the next paycheck date (if no additional approvals are required). Please note that at the end of each calendar year, there may be a period during which effective dates for the new calendar year are not available. In such cases you will need to return later to submit the form.": "The earliest effective date will be the next paycheck date (if no additional approvals are required). Please note that at the end of each calendar year, there may be a period during which effective dates for the new calendar year are not available. In such cases you will need to return later to submit the form.", - "The email address provided in the link is not a valid email address, so impersonation could not start automatically. Correct the email below to impersonate the user.": "The email address provided in the link is not a valid email address, so impersonation could not start automatically. Correct the email below to impersonate the user.", "The estimated housing expenses for {{year}} (e.g., rent/mortgage, utilities, furnishings, repairs, insurance, property taxes).": "The estimated housing expenses for {{year}} (e.g., rent/mortgage, utilities, furnishings, repairs, insurance, property taxes).", "The fair market rental value of the home (furnished, plus utilities)": "The fair market rental value of the home (furnished, plus utilities)", "The following fields exceed their limits: {{fields}}": "The following fields exceed their limits: {{fields}}", @@ -2884,7 +2732,7 @@ "The Staff Conference Savings Fund was created so RMO staff members could set aside funds to help save for costs associated with the U.S. Staff Conference. Since this fund was created, an increasing number of staff have asked if they could use it to help set aside funds for mission trips, upcoming large expenses...": "The Staff Conference Savings Fund was created so RMO staff members could set aside funds to help save for costs associated with the U.S. Staff Conference. Since this fund was created, an increasing number of staff have asked if they could use it to help set aside funds for mission trips, upcoming large expenses...", "The timezone will be used in setting tasks, appointments, completion dates, etc. Please make sure it matches the one your computer is set to.": "The timezone will be used in setting tasks, appointments, completion dates, etc. Please make sure it matches the one your computer is set to.", "The total amount is less than the commitment amount. Would you like to update the commitment amount to match the total? If not, the contact will be moved to the Received column.": "The total amount is less than the commitment amount. Would you like to update the commitment amount to match the total? If not, the contact will be moved to the Received column.", - "The transfer will no longer recur after this date. If left blank, the transfer will recur indefinitely until manually stopped.": "The transfer will no longer recur after this date. If left blank, the transfer will recur indefinitely until manually stopped.", + "The total is the greater of the {{floor}} minimum or your calculated amount.": "The total is the greater of the {{floor}} minimum or your calculated amount.", "The user group for your account is:": "The user group for your account is:", "The user(s) will lose gift data and donor contact data. Consider whether you should notify the user(s).": "The user(s) will lose gift data and donor contact data. Consider whether you should notify the user(s).", "Their gift is {{daysLate}} day late._plural": "Their gift is {{daysLate}} days late.", @@ -2926,7 +2774,6 @@ "This should be the currency that you receive your paychecks in. This will be used when converting donations in other currencies.": "This should be the currency that you receive your paychecks in. This will be used when converting donations in other currencies.", "This should be the organization from which you are paid and most likely correspond to the country in which you are living and serving. This will set your currency conversions for multi-currency accounts to the currency of this organization both on the dashboard and the corresponding contribution reports.": "This should be the organization from which you are paid and most likely correspond to the country in which you are living and serving. This will set your currency conversions for multi-currency accounts to the currency of this organization both on the dashboard and the corresponding contribution reports.", "This should be the place from which you are living and sending out physical communications. This will be used in exports for mailing address information.": "This should be the place from which you are living and sending out physical communications. This will be used in exports for mailing address information.", - "This step is coming soon.": "This step is coming soon.", "This updated request will take the place of your previous request. Once submitted, you can return and make edits until {{date}}. After this date, your request will be processed as is.": "This updated request will take the place of your previous request. Once submitted, you can return and make edits until {{date}}. After this date, your request will be processed as is.", "This will allow {{appName}} to automatically synchronize your donation information.": "This will allow {{appName}} to automatically synchronize your donation information.", "This will clear all entered information and you may start a new form.": "This will clear all entered information and you may start a new form.", @@ -2958,7 +2805,6 @@ "Toggle Menu Panel": "Toggle Menu Panel", "Toggle MPDX Tools Menu": "Toggle MPDX Tools Menu", "Toggle Navigation Panel": "Toggle Navigation Panel", - "Toggle off Headers and Footers in your print settings.": "Toggle off Headers and Footers in your print settings.", "Toggle Preferences Menu": "Toggle Preferences Menu", "Togo": "Togo", "Tokelau": "Tokelau", @@ -2975,13 +2821,11 @@ "Total Amount": "Total Amount", "Total annual salary before deductions, including all 403(b) contributions.": "Total annual salary before deductions, including all 403(b) contributions.", "Total Contribution": "Total Contribution", - "Total Donations": "Total Donations", "Total Donations for this period": "Total Donations for this period", "Total Donations: ": "Total Donations: ", "Total Expenses": "Total Expenses", "Total Expenses:": "Total Expenses:", - "Total Goal": "Total Goal", - "Total Goal (line 5 with {{attrition}} attrition)": "Total Goal (line 5 with {{attrition}} attrition)", + "Total Goal (line 6 with {{attrition}} attrition)": "Total Goal (line 6 with {{attrition}} attrition)", "Total Goal (with attrition)": "Total Goal (with attrition)", "Total Hrs": "Total Hrs", "Total Income": "Total Income", @@ -2993,13 +2837,13 @@ "Total must be less than {{max}}": "Total must be less than {{max}}", "Total must be positive": "Total must be positive", "Total Reimbursable Expenses": "Total Reimbursable Expenses", + "Total reimbursable information": "Total reimbursable information", "Total requested amount": "Total requested amount", "Total Rows:": "Total Rows:", "Total Salary Requested": "Total Salary Requested", "Total Salary Requested / Max Allowable Salary": "Total Salary Requested / Max Allowable Salary", "Total Salary Requested:": "Total Salary Requested:", "Total Solid Support": "Total Solid Support", - "Total Special Needs Goal": "Total Special Needs Goal", "Total Support Goal": "Total Support Goal", "Totals": "Totals", "Totals for Period": "Totals for Period", @@ -3020,6 +2864,7 @@ "Transfer History": "Transfer History", "Transfer History not available": "Transfer History not available", "Transfer stopped successfully": "Transfer stopped successfully", + "TRANSFER TO": "TRANSFER TO", "Transfer updated successfully": "Transfer updated successfully", "Transfers": "Transfers", "Trinidad and Tobago": "Trinidad and Tobago", @@ -3050,7 +2895,6 @@ "Unable to move contact to the \"Received\" column as gift has not been received by Cru. Status set to \"Committed\".": "Unable to move contact to the \"Received\" column as gift has not been received by Cru. Status set to \"Committed\".", "Unable to move Excluded Contact here. If you want to add this Excluded contact to this appeal, please add them to Asked.": "Unable to move Excluded Contact here. If you want to add this Excluded contact to this appeal, please add them to Asked.", "Unable to remove commitment from appeal": "Unable to remove commitment from appeal", - "Unable to remove connection": "Unable to remove connection", "Unable to reset account": "Unable to reset account", "Unable to save your CSV import settings - See help docs or send us a message with your CSV attached": "Unable to save your CSV import settings - See help docs or send us a message with your CSV attached", "Unable to set appeal as primary": "Unable to set appeal as primary", @@ -3065,7 +2909,6 @@ "United States": "United States", "United States Minor Outlying Islands": "United States Minor Outlying Islands", "Unknown": "Unknown", - "Unknown Assignment Status": "Unknown Assignment Status", "unknown at this time": "unknown at this time", "Unknown Category": "Unknown Category", "Unknown Subcategory": "Unknown Subcategory", @@ -3141,7 +2984,6 @@ "View Complete Calculations": "View Complete Calculations", "View Current MHA": "View Current MHA", "View detailed instructions": "View detailed instructions", - "View details for {{name}}": "View details for {{name}}", "View Gifts": "View Gifts", "View in Dashboard": "View in Dashboard", "View In Dashboard": "View In Dashboard", @@ -3155,7 +2997,7 @@ "Virgin Islands, U.S.": "Virgin Islands, U.S.", "Voluntary 403b Retirement Plan": "Voluntary 403b Retirement Plan", "Wallis and Futuna": "Wallis and Futuna", - "Want your monthly transfer to end at a certain point? You can set an end date—super handy! Just a heads-up: once it’s there, it can’t be removed, but you can change it to a different date if needed.": "Want your monthly transfer to end at a certain point? You can set an end date—super handy! Just a heads-up: once it’s there, it can’t be removed, but you can change it to a different date if needed.", + "Want your monthly transfer to end at a certain point? You can set a stop date—super handy! Just a heads-up: once it’s there, it can’t be removed, but you can change it to a different date if needed.": "Want your monthly transfer to end at a certain point? You can set a stop date—super handy! Just a heads-up: once it’s there, it can’t be removed, but you can change it to a different date if needed.", "WARNING: Please read the implications of deleting this account.": "WARNING: Please read the implications of deleting this account.", "WARNING: Please read the implications of deleting this user.": "WARNING: Please read the implications of deleting this user.", "We are unable accept Additional Salary Requests at this time. Please contact <1>Payroll@cru.org if you have any questions.": "We are unable accept Additional Salary Requests at this time. Please contact <1>Payroll@cru.org if you have any questions.", @@ -3165,7 +3007,7 @@ "We see you're not on staff with Cru.": "We see you're not on staff with Cru.", "We strongly recommend only making changes in {{appName}}.": "We strongly recommend only making changes in {{appName}}.", "We will review your information and you will receive notice for your {{approval}}.": "We will review your information and you will receive notice for your {{approval}}.", - "We will review your request through our <2>Progressive Approvals process. For the {{amount}} you are requesting, this will take {{timeframe}} as it needs to be signed off by {{approvers}}. This may affect your selected effective date.": "We will review your request through our <2>Progressive Approvals process. For the {{amount}} you are requesting, this will take {{timeframe}} as it needs to be signed off by {{approvers}}. This may affect your selected effective date.", + "We will review your request through our Progressive Approvals process. For the {{amount}} you are requesting, this will take {{timeframe}} as it needs to be signed off by {{approvers}}. This may affect your selected effective date.": "We will review your request through our Progressive Approvals process. For the {{amount}} you are requesting, this will take {{timeframe}} as it needs to be signed off by {{approvers}}. This may affect your selected effective date.", "We will review your updated request.": "We will review your updated request.", "Website": "Website", "WeChat": "WeChat", @@ -3184,20 +3026,14 @@ "What are your total expenses for the last staff conference? Divide that by 36 to get your monthly needs. We encourage you to set up a regular monthly deposit for this amount from your staff account to your Conference.": "What are your total expenses for the last staff conference? Divide that by 36 to get your monthly needs. We encourage you to set up a regular monthly deposit for this amount from your staff account to your Conference.", "What country are you in?": "What country are you in?", "What do you want to call this goal?": "What do you want to call this goal?", - "What is your expected ministry assignment location?": "What is your expected ministry assignment location?", - "What is your monthly payment for all of your {{debtType}}?": "What is your monthly payment for all of your {{debtType}}?", - "What ministry are you expecting to serve with?": "What ministry are you expecting to serve with?", - "What type of assignment are you expecting?": "What type of assignment are you expecting?", + "What's New": "What's New", "WhatsApp": "WhatsApp", "When you add a Google account to {{appName}}, Google will ask you what {{appName}} should be allowed to access. Please select ALL of the checkboxes.

Otherwise, {{appName}} may not work properly.": "When you add a Google account to {{appName}}, Google will ask you what {{appName}} should be allowed to access. Please select ALL of the checkboxes.

Otherwise, {{appName}} may not work properly.", "Which account would you like to see by default when you open {{appName}}?": "Which account would you like to see by default when you open {{appName}}?", - "Which describes the sessions you are attending?": "Which describes the sessions you are attending?", - "Which of the following describes your NSO housing?": "Which of the following describes your NSO housing?", "Widowed": "Widowed", "With kids": "With kids", "Withdrawal": "Withdrawal", "Work": "Work", - "Work Comp": "Work Comp", "Work Comp for Part-time": "Work Comp for Part-time", "Workers Compensation": "Workers Compensation", "Would you like {{appName}} to email Chalkline your newsletter list and open their order form in a new tab?": "Would you like {{appName}} to email Chalkline your newsletter list and open their order form in a new tab?", @@ -3238,10 +3074,10 @@ "You are updating all contacts visible on this page, setting the first {{source}} email address as the primary email address. If no such email address exists the contact will not be updated. Are you sure you want to do this?": "You are updating all contacts visible on this page, setting the first {{source}} email address as the primary email address. If no such email address exists the contact will not be updated. Are you sure you want to do this?", "You can add contacts to your appeal based on their status and/or tags. You can also add additional contacts individually at a later time.": "You can add contacts to your appeal based on their status and/or tags. You can also add additional contacts individually at a later time.", "You can change the account name in {{appName}} into something that is more identifiable to you. This will not change the account name with your organization.": "You can change the account name in {{appName}} into something that is more identifiable to you. This will not change the account name with your organization.", + "You can combine certain categories of data into single rows. This may be useful for long date ranges (e.g., \"Year to Date\").\n Select which categories to consolidate. Each category remains separate.": "You can combine certain categories of data into single rows. This may be useful for long date ranges (e.g., \"Year to Date\").\n Select which categories to consolidate. Each category remains separate.", "You can import {{page}}s from another service or add a new {{page}}.": "You can import {{page}}s from another service or add a new {{page}}.", "You can migrate all your contact information and history from TntConnect into {{appName}}. Most of your information will import straight into {{appName}}, including contact info, task history with notes, notes, user groups, and appeals. {{appName}} hides contacts with any of the not interested statuses, including 'Not Interested' and 'Never Ask' in {{appName}} (these contacts are imported, but will only show up if you search for hidden contacts).": "You can migrate all your contact information and history from TntConnect into {{appName}}. Most of your information will import straight into {{appName}}, including contact info, task history with notes, notes, user groups, and appeals. {{appName}} hides contacts with any of the not interested statuses, including 'Not Interested' and 'Never Ask' in {{appName}} (these contacts are imported, but will only show up if you search for hidden contacts).", "You can only merge up to 8 contacts at a time.": "You can only merge up to 8 contacts at a time.", - "You can return to this New Staff Goal Calculation under HR Tools in your top navigation at any time. If you have any questions or need to make changes to your goal, please contact your coach.": "You can return to this New Staff Goal Calculation under HR Tools in your top navigation at any time. If you have any questions or need to make changes to your goal, please contact your coach.", "You can setup an organization account to import historic donations or add a new donation.": "You can setup an organization account to import historic donations or add a new donation.", "You can setup an organization account to import them or add a new donation.": "You can setup an organization account to import them or add a new donation.", "You can setup an organization account to import your designation accounts.": "You can setup an organization account to import your designation accounts.", @@ -3280,8 +3116,6 @@ "You must select at least 2 contacts to merge.": "You must select at least 2 contacts to merge.", "You need to create a list on Mailchimp that {{appName}} can use for your newsletter.": "You need to create a list on Mailchimp that {{appName}} can use for your newsletter.", "You only have access to this account, so you cannot merge it with another one yet. Share this account with\n someone else first. Once they accept your share, you will be able to merge your accounts together.": "You only have access to this account, so you cannot merge it with another one yet. Share this account with\n someone else first. Once they accept your share, you will be able to merge your accounts together.", - "You or your spouse has a pending Additional Salary Request, so this request needs additional approval. This will take {{timeframe}} as it needs to be signed off by {{approvers}}. This may affect your selected effective date.": "You or your spouse has a pending Additional Salary Request, so this request needs additional approval. This will take {{timeframe}} as it needs to be signed off by {{approvers}}. This may affect your selected effective date.", - "You or your spouse has a pending Additional Salary Request, so this request needs additional approval. This will take {{timeframe}} as it needs to be signed off by the {{approver}}. This may affect your selected effective date.": "You or your spouse has a pending Additional Salary Request, so this request needs additional approval. This will take {{timeframe}} as it needs to be signed off by the {{approver}}. This may affect your selected effective date.", "You will be redirected soon...": "You will be redirected soon...", "You will be taken to your organization's donation services system to grant {{appName}} permission to access your donation data.": "You will be taken to your organization's donation services system to grant {{appName}} permission to access your donation data.", "You will each get an email (you might need to check your spam) and will need to accept access to each other’s accounts.": "You will each get an email (you might need to check your spam) and will need to accept access to each other’s accounts.", @@ -3294,7 +3128,7 @@ "You've successfully submitted your {{formTitle}}!": "You've successfully submitted your {{formTitle}}!", "You've successfully submitted your Salary Calculation Form!": "You've successfully submitted your Salary Calculation Form!", "You've successfully updated your {{formTitle}}!": "You've successfully updated your {{formTitle}}!", - "Your {{combined}} Gross Requested Salary exceeds your {{combined}} Maximum Allowable Salary. Please make adjustments to your Salary Request above or fill out the Approval Process Section below to request a higher amount through our <7>Progressive Approvals process. This will take {{timeframe}} as it needs to be signed off by the {{approver}}. This may affect your selected effective date.": "Your {{combined}} Gross Requested Salary exceeds your {{combined}} Maximum Allowable Salary. Please make adjustments to your Salary Request above or fill out the Approval Process Section below to request a higher amount through our <7>Progressive Approvals process. This will take {{timeframe}} as it needs to be signed off by the {{approver}}. This may affect your selected effective date.", + "Your {{combined}} Gross Requested Salary exceeds your {{combined}} Maximum Allowable Salary. Please make adjustments to your Salary Request above or fill out the Approval Process Section below to request a higher amount through our Progressive Approvals process. This may take {{timeframe}} as it needs to be signed off by the {{approver}}. This may affect your selected effective date.": "Your {{combined}} Gross Requested Salary exceeds your {{combined}} Maximum Allowable Salary. Please make adjustments to your Salary Request above or fill out the Approval Process Section below to request a higher amount through our Progressive Approvals process. This may take {{timeframe}} as it needs to be signed off by the {{approver}}. This may affect your selected effective date.", "Your Additional Salary Request": "Your Additional Salary Request", "Your Annual MHA Total": "Your Annual MHA Total", "Your Combined Gross Requested Salary is within your Combined Maximum Allowable Salary. However, {{name}}'s Gross Requested Salary exceeds their individual Maximum Allowable Salary. If this is correct, please provide reasoning for why {{name}}'s Requested Salary should exceed {{cap}} in the Additional Information section below or make changes to how your Requested Salary is distributed above.": "Your Combined Gross Requested Salary is within your Combined Maximum Allowable Salary. However, {{name}}'s Gross Requested Salary exceeds their individual Maximum Allowable Salary. If this is correct, please provide reasoning for why {{name}}'s Requested Salary should exceed {{cap}} in the Additional Information section below or make changes to how your Requested Salary is distributed above.", @@ -3307,7 +3141,6 @@ "Your form is missing information.": "Your form is missing information.", "Your Goal": "Your Goal", "Your Google import has started and your contacts will be in {{appName}} shortly. We will email you when your import is complete.": "Your Google import has started and your contacts will be in {{appName}} shortly. We will email you when your import is complete.", - "Your gross monthly pay broken down by category, calculated from the values entered in Setup.": "Your gross monthly pay broken down by category, calculated from the values entered in Setup.", "Your gross request is within your Maximum Allowable Salary.": "Your gross request is within your Maximum Allowable Salary.", "Your Gross Requested Salary": "Your Gross Requested Salary", "Your Information": "Your Information", @@ -3320,28 +3153,21 @@ "Your MHA": "Your MHA", "Your MHA Request": "Your MHA Request", "Your MHA Request Summary": "Your MHA Request Summary", - "Your MPD Goal": "Your MPD Goal", "Your Net Additional Salary calculated below represents the amount you will receive as an additional salary check (before taxes) and is equal to the amount you are requesting minus any amount being contributed to your 403(b).": "Your Net Additional Salary calculated below represents the amount you will receive as an additional salary check (before taxes) and is equal to the amount you are requesting minus any amount being contributed to your 403(b).", - "Your Person Number is unique and assigned to you by Oracle HCM, Cru's new HR system. It replaces the Employee ID (EMPLID) previously used in PeopleSoft. If you need help with anything related to HR or payroll — salary calculations, housing allowance, or additional salary requests — this is the number HR staff will use to look you up in the system.": "Your Person Number is unique and assigned to you by Oracle HCM, Cru's new HR system. It replaces the Employee ID (EMPLID) previously used in PeopleSoft. If you need help with anything related to HR or payroll — salary calculations, housing allowance, or additional salary requests — this is the number HR staff will use to look you up in the system.", - "Your request causes your combined Total Requested Salary to exceed your combined Maximum Allowable Salary.": "Your request causes your combined Total Requested Salary to exceed your combined Maximum Allowable Salary.", "Your request causes your Total Requested Salary to exceed your Maximum Allowable Salary.": "Your request causes your Total Requested Salary to exceed your Maximum Allowable Salary.", "Your request has been sent to payroll and you will receive your additional salary separately from your regular paycheck by 9/25/2025.": "Your request has been sent to payroll and you will receive your additional salary separately from your regular paycheck by 9/25/2025.", "Your request requires additional approval because your Gross Salary exceeds your Maximum Allowable Salary. Do you want to continue?": "Your request requires additional approval because your Gross Salary exceeds your Maximum Allowable Salary. Do you want to continue?", - "Your request requires additional approval. Do you want to continue?": "Your request requires additional approval. Do you want to continue?", "Your request requires additional approval. Please fill in the information below to continue.": "Your request requires additional approval. Please fill in the information below to continue.", - "Your request requires additional approvals and cannot be submitted online. SOSA staff can have requests exceeding the {{cap}} cap approved for certain geographic locations with the appropriate levels of approval.<4><5>Please contact <8>payroll@cru.org for further assistance.": "Your request requires additional approvals and cannot be submitted online. SOSA staff can have requests exceeding the {{cap}} cap approved for certain geographic locations with the appropriate levels of approval.<4><5>Please contact <8>payroll@cru.org for further assistance.", "Your request requires Board approval. Please review the information below to continue.": "Your request requires Board approval. Please review the information below to continue.", "Your request will be sent to HR Services.": "Your request will be sent to HR Services.", "Your request will be sent to payroll.": "Your request will be sent to payroll.", "Your Requested Salary plus 403(b) and taxes.": "Your Requested Salary plus 403(b) and taxes.", "Your Salary Calculation Form": "Your Salary Calculation Form", - "Your spouse has a pending Additional Salary Request or Salary Request, so this request needs additional approval.": "Your spouse has a pending Additional Salary Request or Salary Request, so this request needs additional approval.", "Your TntConnect data is importing. We'll send you an email as soon as it's all done and ready! Please be aware that it could take up to 12 hours.": "Your TntConnect data is importing. We'll send you an email as soon as it's all done and ready! Please be aware that it could take up to 12 hours.", "Your Total Additional Salary Request exceeds your remaining allowable salary.": "Your Total Additional Salary Request exceeds your remaining allowable salary.", "Your total additional salary requested exceeds your account balance.": "Your total additional salary requested exceeds your account balance.", "Your updated request will be sent to payroll.": "Your updated request will be sent to payroll.", "Your work will not be saved and the board will review your request as previously submitted.": "Your work will not be saved and the board will review your request as previously submitted.", - "Yourself": "Yourself", "Zambia": "Zambia", "Zimbabw": "Zimbabw", "Zip": "Zip" diff --git a/public/locales/en/translation_old.json b/public/locales/en/translation_old.json index 759a87c163..8c8e584314 100644 --- a/public/locales/en/translation_old.json +++ b/public/locales/en/translation_old.json @@ -1,12 +1,9 @@ { " so you can log back in with your official key account.": " so you can log back in with your official key account.", "--All Hidden --": "--All Hidden --", - "(Subtotal + Attrition) × {{rate}}": "(Subtotal + Attrition) × {{rate}}", - "(Subtotal + Credit Card Fees + Attrition) × {{rate}}": "(Subtotal + Credit Card Fees + Attrition) × {{rate}}", "{{appName}}": "{{appName}}", "{{appName}} couldn't save your configuration changes for MailChimp": "{{appName}} couldn't save your configuration changes for MailChimp", "{{appName}} removed your integration with MailChimp": "{{appName}} removed your integration with MailChimp", - "{{mode}} Minister's Housing Allowance Request": "{{mode}} Minister's Housing Allowance Request", "{{name}} may request up to their Board Approved MHA Amount of {{approvedAmount}}.": "{{name}} may request up to their Board Approved MHA Amount of {{approvedAmount}}.", "{{status}}": "{{status}}", "{{tooldesc}}": "{{tooldesc}}", @@ -16,7 +13,6 @@ "+ Add Goal": "+ Add Goal", "+ Add Income": "+ Add Income", "<0>A Minister's Housing Allowance Request is a form ministers complete to designate part of their compensation as tax-free housing allowance. To complete this form for the {nextYear} tax year, you'll need:": "<0>A Minister's Housing Allowance Request is a form ministers complete to designate part of their compensation as tax-free housing allowance. To complete this form for the {nextYear} tax year, you'll need:", - "<0>You can now change the reminder status of any of your ministry partners online! Your current list and related information is displayed below. To change the reminder status of any of your ministry partners, use the drop-down boxes in the \"Reminder Status\" column.<1>When you're done, click the \"Save\" button at the bottom of the page. Wondering how the <2>Reminder System works and how it differs from the Receipting System? Check out <5>Ministry Partner Reminder help.": "<0>You can now change the reminder status of any of your ministry partners online! Your current list and related information is displayed below. To change the reminder status of any of your ministry partners, use the drop-down boxes in the \"Reminder Status\" column.<1>When you're done, click the \"Save\" button at the bottom of the page. Wondering how the <2>Reminder System works and how it differs from the Receipting System? Check out <5>Ministry Partner Reminder help.", "

This contact will be anonymized in your {{appName}} organization. This is permanent and can't be recovered. Only anonymize if you are 100% confident that you are looking at the correct contact.


A contact placeholder will remain with a name like “DataPrivacy, Deleted”. Gift data will remain. Other data such as notes and tasks will be removed. Status will be set as “Never Ask”. Newsletter set to 'N/A'. You can request removal across all other systems at dsar@cru.org.

": "

This contact will be anonymized in your {{appName}} organization. This is permanent and can't be recovered. Only anonymize if you are 100% confident that you are looking at the correct contact.


A contact placeholder will remain with a name like “DataPrivacy, Deleted”. Gift data will remain. Other data such as notes and tasks will be removed. Status will be set as “Never Ask”. Newsletter set to 'N/A'. You can request removal across all other systems at dsar@cru.org.

", "100% - Roth + Traditional 403(b) %": "100% - Roth + Traditional 403(b) %", "12 Month Partner Report": "12 Month Partner Report", @@ -28,10 +24,8 @@ "Add or change the organizations that sync donation information with this\n {{appName}} account. Removing an organization will not remove past information,\n but will prevent future donations and contacts from syncing.": "Add or change the organizations that sync donation information with this\n {{appName}} account. Removing an organization will not remove past information,\n but will prevent future donations and contacts from syncing.", "Add organizations that sync donation information with this {{appName}} account. That \n organization will have the ability to manage or delete your account. Removing an \n organization will not remove past information, but will prevent future donations \n and contacts from syncing.": "Add organizations that sync donation information with this {{appName}} account. That \n organization will have the ability to manage or delete your account. Removing an \n organization will not remove past information, but will prevent future donations \n and contacts from syncing.", "Add Referrals": "Add Referrals", - "Add Stop Date": "Add Stop Date", "Age is required": "Age is required", "Age range must be one of the options": "Age range must be one of the options", - "Amount cannot exceed {{max}}": "Amount cannot exceed {{max}}", "Amount of time before notification": "Amount of time before notification", "Amount Received": "Amount Received", "And error occured.": "And error occured.", @@ -46,7 +40,6 @@ "Appts Produced": "Appts Produced", "Archive": "Archive", "Are you sure you want to delete": "Are you sure you want to delete", - "Are you sure you want to delete <2>{goal.name ?? t('Unnamed Goal')}? Deleting this goal will remove it permanently.": "Are you sure you want to delete <2>{goal.name ?? t('Unnamed Goal')}? Deleting this goal will remove it permanently.", "Are you sure you want to stop this recurring transfer?": "Are you sure you want to stop this recurring transfer?", "Are you sure you wish to {{action}} the {{count}} selected tasks?": "Are you sure you wish to {{action}} the {{count}} selected tasks?", "Are you sure you wish to {{action}} the {{count}} selected tasks?_plural": "Are you sure you wish to {{action}} the {{count}} selected tasks?", @@ -63,8 +56,8 @@ "Attempted - Left Message": "Attempted - Left Message", "ATTEMPTED_LEFT_MESSAGE": "Attempted - Left Message", "Automatically log sent MailChimp campaigns in contact task history": "Automatically log sent MailChimp campaigns in contact task history", + "Base": "Base", "Benefits Charge": "Benefits Charge", - "Benefits is a required field": "Benefits is a required field", "Benefits plan is required": "Benefits plan is required", "Benefits plan must be one of the options": "Benefits plan must be one of the options", "BOTH": "Both", @@ -93,7 +86,6 @@ "Comparison and details about Roth vs Traditional 403(b) retirement plans.": "Comparison and details about Roth vs Traditional 403(b) retirement plans.", "Complete {{activityType}}": "Complete {{activityType}}", "COMPLETED": "Completed", - "Conference Savings Account": "Conference Savings Account", "Congratulations!<1>You're all set!": "Congratulations!<1>You're all set!", "Connect MailChimp": "Connect MailChimp", "Contact commitment info updated!": "Contact commitment info updated!", @@ -110,7 +102,6 @@ "CULTIVATE_RELATIONSHIP": "Cultivate Relationship", "Current": "Current", "Current MHA": "Current MHA", - "Current Salary": "Current Salary", "Current step is not defined or does not exist.": "Current step is not defined or does not exist.", "DataServer": "DataServer", "Date Committed": "Date Committed", @@ -133,22 +124,17 @@ "Due Date {{ minimumDate }} - {{ maximumDate }}": "Due Date: {{ minimumDate }} - {{ maximumDate }}", "e.g., Emergency Fund, Equipment": "e.g., Emergency Fund, Equipment", "e.g., Freelance, Side Business": "e.g., Freelance, Side Business", - "Earnings REB": "Earnings REB", "Edit Address Icon": "Edit Address Icon", "Edit Contact Details": "Edit Contact Details", "Edit Contact Mailing Details": "Edit Contact Mailing Details", "Edit Mailing": "Edit Mailing", "Edit Other Icon": "Edit Other Icon", - "Edit Stop Date": "Edit Stop Date", "effective_date_banner_message": "effective_date_banner_message", "Electronic Messages": "Electronic Messages", "EMAIL": "Email", "Email Newsletter List": "Email Newsletter List", - "End date must be after transfer date": "End date must be after transfer date", - "Enter amounts for the following categories of reimbursable and ministry expenses. The <2>MPGA tool on StaffWeb can show you your averages in some of these categories. If you did not take full reimbursements for the entire year, or if your reimbursements were abnormally high (e.g. you had a surgery or bought a new computer), or low (e.g. no summer mission), you will want to adjust the averages from the MPGA to reflect an average year. Click the link above, go to the Income/Expenses tab, and look under the Ministry Expenses section.": "Enter amounts for the following categories of reimbursable and ministry expenses. The <2>MPGA tool on StaffWeb can show you your averages in some of these categories. If you did not take full reimbursements for the entire year, or if your reimbursements were abnormally high (e.g. you had a surgery or bought a new computer), or low (e.g. no summer mission), you will want to adjust the averages from the MPGA to reflect an average year. Click the link above, go to the Income/Expenses tab, and look under the Ministry Expenses section.", "Entertainment": "Entertainment", "Error occurred while updating mailing information": "Error occurred while updating mailing information", - "Error saving changes": "Error saving changes", "Error updating phone numbers": "Error updating phone numbers", "EVERY_2_MONTHS": "Every 2 Months", "EVERY_2_WEEKS": "Every 2 Weeks", @@ -160,8 +146,6 @@ "EXPIRED_REFERRAL": "Expired Referral", "Facebook Message": "Facebook Message", "FACEBOOK_MESSAGE": "Facebook Message", - "Failed to add stop date": "Failed to add stop date", - "Failed to update stop date": "Failed to update stop date", "fairRentalValueQuestion1": "fairRentalValueQuestion1", "fairRentalValueQuestion2": "fairRentalValueQuestion2", "Family size is required": "Family size is required", @@ -171,7 +155,6 @@ "Filter ({{count}})_one": "Filter ({{count}})", "Filter ({{count}})_other": "Filter ({{count}})", "Filter ({{count}})_plural": "Filter ({{count}})", - "Final total goal including attrition rate applied to line 6.": "Final total goal including attrition rate applied to line 6.", "Financial": "Financial", "First name is required": "First name is required", "First you need to ": "First you need to ", @@ -196,14 +179,11 @@ "Good Evening, {{ firstName }}.": "Good Evening, {{ firstName }}.", "Good Morning, {{ firstName }}.": "Good Morning, {{ firstName }}.", "Greeting (used in export)": "Greeting (used in export)", - "Help logo": "Help logo", "Hide Right Panel": "Hide Right Panel", "Historic": "Historic", "HOURS": "Hours", "Housing": "Housing", "How the notification will be sent": "How the notification will be sent", - "HR Tools - Paid with Designation Support Goal Calculator": "HR Tools - Paid with Designation Support Goal Calculator", - "HR Tools | MHA Calculation Form": "HR Tools | MHA Calculation Form", "If blank you will not be notified": "If blank you will not be notified", "If you are already logged in using your ministry account, you'll need to contact your donation services team to request access.": "If you are already logged in using your ministry account, you'll need to contact your donation services team to request access.", "If you have an existing MailChimp list you'd like to use, Great!\n Or, create a new one for your {{appName}} connection.": "If you have an existing MailChimp list you'd like to use, Great!\n Or, create a new one for your {{appName}} connection.", @@ -215,6 +195,7 @@ "Incident income must be positive": "Incident income must be positive", "Income & Expenses": "Income & Expenses", "Income Label": "Income Label", + "Incomplete": "Incomplete", "Individual Completed": "Individual Completed", "Information about gross annual salary calculations and components.": "Information about gross annual salary calculations and components.", "Information about medical expense deductions and healthcare benefits.": "Information about medical expense deductions and healthcare benefits.", @@ -244,10 +225,9 @@ "Medical Mileage": "Medical Mileage", "Messages": "Messages", "MHA amount per paycheck must be positive": "MHA amount per paycheck must be positive", - "MHA Calculation Form": "MHA Calculation Form", "MHA Calculator": "MHA Calculator", + "Minimum": "Minimum", "Minimum Due Date {{ minimumDate }}": "Minimum Due Date: {{ minimumDate }}", - "Minister's Housing Allowance Request": "Minister's Housing Allowance Request", "Ministry Location": "Ministry Location", "Ministry Mileage": "Ministry Mileage", "Ministry Partner Development": "Ministry Partner Development", @@ -255,7 +235,6 @@ "MOBILE": "Mobile", "MONTHLY": "Monthly", "Monthly Activity": "Monthly Activity", - "Monthly Base × (1 + Geographic Multiplier)": "Monthly Base × (1 + Geographic Multiplier)", "Monthly breakdown of gross salary before deductions.": "Monthly breakdown of gross salary before deductions.", "Monthly Commitment Average": "Monthly Commitment Average", "Monthly Commitment Goal": "Monthly Commitment Goal", @@ -265,12 +244,10 @@ "Monthly expenses must be positive": "Monthly expenses must be positive", "MPDX": "MPDX", "MPGA Monthly Report": "MPGA Monthly Report", - "Must be greater than $0.": "Must be greater than $0.", "Must use a positive number for Admin Cost": "Must use a positive number for Admin Cost", "Must use a positive number for Initial Goal": "Must use a positive number for Initial Goal", "Must use a positive number for Letter Cost": "Must use a positive number for Letter Cost", "Name Account": "Name Account", - "Need to update the amount you're transferring each month? No problem! Just set a stop date for the end of the current month on your existing transfer. After that, go ahead and set up a brand-new monthly transfer with the updated amount.": "Need to update the amount you're transferring each month? No problem! Just set a stop date for the end of the current month on your existing transfer. After that, go ahead and set up a brand-new monthly transfer with the updated amount.", "NEVER_ASK": "Never Ask", "NEVER_CONTACTED": "Never Contacted", "New Appeal Pledges": "New Appeal Pledges", @@ -288,7 +265,6 @@ "Newsletter: {{newsletter}}": "Newsletter: {{newsletter}}", "Next Ask Increase": "Next Ask Increase", "Next, ": "Next, ", - "No changes have been made": "No changes have been made", "No Comments to show.": "No Comments to show.", "No Contacts to show.": "No Contacts to show.", "No phone numbers were updated": "No phone numbers were updated", @@ -298,7 +274,6 @@ "Non Staff Spouse Income": "Non Staff Spouse Income", "NONE": "None", "NOT_INTERESTED": "Not Interested", - "Note (Optional)": "Note (Optional)", "Notification": "Notification", "On your email newsletter list but has no people with a valid email address": "On your email newsletter list but has no people with a valid email address", "On: {{when}}": "On: {{when}}", @@ -321,8 +296,6 @@ "pending": "pending", "Percentage calculation for combined Roth and Traditional 403(b) contributions.": "Percentage calculation for combined Roth and Traditional 403(b) contributions.", "Period": "Period", - "Person Number: {{personNumbers}}_one": "Person Number: {{personNumbers}}", - "Person Number: {{personNumbers}}_other": "Person Numbers: {{personNumbers}}", "Phone Calls": "Phone Calls", "Phone Dials": "Phone Dials", "Phone numbers updated!": "Phone numbers updated!", @@ -330,6 +303,7 @@ "Platform": "Platform", "Please choose a list to sync with MailChimp.": "Please choose a list to sync with MailChimp.", "Please enter the amount of your salary you would like to request as MHA below. If you have a pending MHA Request for a new amount, it will not apply to this salary calculation but you can submit a new Salary Calculation Form after it is approved.": "Please enter the amount of your salary you would like to request as MHA below. If you have a pending MHA Request for a new amount, it will not apply to this salary calculation but you can submit a new Salary Calculation Form after it is approved.", + "Plus": "Plus", "Prayer Request": "Prayer Request", "PRAYER_REQUEST": "Prayer Request", "PRE_CALL_LETTER": "Pre-Call Letter", @@ -369,7 +343,6 @@ "Reports - Designation Accounts": "Reports - Designation Accounts", "Reports - Donations": "Reports - Donations", "Reports - Expected Monthly Total": "Reports - Expected Monthly Total", - "Reports - Goal Calculation": "Reports - Goal Calculation", "Reports - Minister's Housing Allowance": "Reports - Minister's Housing Allowance", "Reports - Monthly Report (Partner Currency)": "Reports - Monthly Report (Partner Currency)", "Reports - Monthly Report (Salary Currency)": "Reports - Monthly Report (Salary Currency)", @@ -378,7 +351,6 @@ "Reports - Partner Giving Analysis": "Reports - Partner Giving Analysis", "Reports - Salary": "Reports - Salary", "requestSummaryCardInfo": "requestSummaryCardInfo", - "Required field.": "Required field.", "RESEARCH_ABANDONED": "Research Abandoned", "Resetting the welcome tour failed.": "Resetting the welcome tour failed.", "Resulting Appointments": "Resulting Appointments", @@ -388,7 +360,6 @@ "Roth 403(b) contribution must be positive": "Roth 403(b) contribution must be positive", "Roth 403(b), Traditional 403b": "Roth 403(b), Traditional 403b", "rows per page": "rows per page", - "Salary Calculator": "Salary Calculator", "Salary Request": "Salary Request", "Saving setup phase failed.": "Saving setup phase failed.", "Scheduled": "Scheduled", @@ -415,13 +386,9 @@ "Spouse's Personal": "Spouse's Personal", "Staff Development": "Staff Development", "Status: {{status}}": "Status: {{status}}", - "Stop Date": "Stop Date", - "Stop date added successfully": "Stop date added successfully", - "Stop date updated successfully": "Stop date updated successfully", "Stop ongoing Transfer": "Stop ongoing Transfer", "Stop Transfer: ${{transfer}}": "Stop Transfer: ${{transfer}}", "Stopping Impersonating and redirecting you to the legacy MPDX": "Stopping Impersonating and redirecting you to the legacy MPDX", - "Subtotal × {{rate}}": "Subtotal × {{rate}}", "Subtotal with 12% admin charge": "Subtotal with 12% admin charge", "Summer Missions": "Summer Missions", "Supplies": "Supplies", @@ -429,7 +396,6 @@ "Support Letter": "Support Letter", "Support Letters": "Support Letters", "SUPPORT_LETTER": "Support Letter", - "Swap": "Swap", "Tag {{tag}}": "Tag: {{tag}}", "Talk To In Person": "Talk To In Person", "TALK_TO_IN_PERSON": "Talk To In Person", @@ -457,7 +423,6 @@ "That's it! Set it and leave it! Now your MailChimp list is\n continuously up to date with your {{appName}} Contacts. That's just\n the surface. Click over to the {{appName}} Help site for more in-depth\n details.": "That's it! Set it and leave it! Now your MailChimp list is\n continuously up to date with your {{appName}} Contacts. That's just\n the surface. Click over to the {{appName}} Help site for more in-depth\n details.", "The amount entered here will be reflected in your total MPD goal. To look at your goal without spouse's salary, leave this blank.": "The amount entered here will be reflected in your total MPD goal. To look at your goal without spouse's salary, leave this blank.", "The Key / Relay": "The Key / Relay", - "The total is the greater of the {{floor}} minimum or your calculated amount.": "The total is the greater of the {{floor}} minimum or your calculated amount.", "There is an error with your MailChimp connection. Please disconnect and reconnect to MailChimp.": "There is an error with your MailChimp connection. Please disconnect and reconnect to MailChimp.", "These {{number}} contacts have been previously excluded from this appeal. Are you certain you wish to add them?": "These {{number}} contacts have been previously excluded from this appeal. Are you certain you wish to add them?", "these resources from Ramsey Solutions": "these resources from Ramsey Solutions", @@ -493,12 +458,9 @@ "Tools - Merge Contacts": "Tools - Merge Contacts", "Tools - Merge People": "Tools - Merge People", "Total Goal (line 16 x 1.06 attrition)": "Total Goal (line 16 x 1.06 attrition)", - "Total Goal (line 6 with {{attrition}} attrition)": "Total Goal (line 6 with {{attrition}} attrition)", - "Total reimbursable information": "Total reimbursable information", "Traditional 403(b) contribution must be positive": "Traditional 403(b) contribution must be positive", "Transfer failed": "Transfer failed", "Transfer successful": "Transfer successful", - "TRANSFER TO": "TRANSFER TO", "Transportation": "Transportation", "Travel": "Travel", "Twelve month report table": "Twelve month report table", @@ -512,15 +474,12 @@ "Video Call": "Video Call", "View Spouse": "View Spouse", "View Your Information": "View Your Information", - "Want your monthly transfer to end at a certain point? You can set a stop date—super handy! Just a heads-up: once it’s there, it can’t be removed, but you can change it to a different date if needed.": "Want your monthly transfer to end at a certain point? You can set a stop date—super handy! Just a heads-up: once it’s there, it can’t be removed, but you can change it to a different date if needed.", "Week on MPD:": "Week on MPD:", "WEEKLY": "Weekly", "What are your one-time financial goals?": "What are your one-time financial goals?", - "What's New": "What's New", "Yearly": "Yearly", "Years on staff is required": "Years on staff is required", "Years on staff must be one of the options": "Years on staff must be one of the options", - "You can combine certain categories of data into single rows. This may be useful for long date ranges (e.g., \"Year to Date\").\n Select which categories to consolidate. Each category remains separate.": "You can combine certain categories of data into single rows. This may be useful for long date ranges (e.g., \"Year to Date\").\n Select which categories to consolidate. Each category remains separate.", "You can setup an organization account to import your financial accounts.": "You can setup an organization account to import your financial accounts.", "You have {{amount}} possible duplicate contacts. This is sometimes caused when you imported data into {{appName}}. We recommend reconciling these as soon as possible. Please select the duplicate that should win the merge. No data will be lost.": "You have {{amount}} possible duplicate contacts. This is sometimes caused when you imported data into {{appName}}. We recommend reconciling these as soon as possible. Please select the duplicate that should win the merge. No data will be lost.", "You have {{amount}} possible duplicate contacts. This is sometimes caused when you imported data into {{appName}}. We recommend reconciling these as soon as possible. Please select the duplicate that should win the merge. No data will be lost. ": "You have {{amount}} possible duplicate contacts. This is sometimes caused when you imported data into {{appName}}. We recommend reconciling these as soon as possible. Please select the duplicate that should win the merge. No data will be lost. ", @@ -534,7 +493,6 @@ "You may request up to your Board Approved MHA Amount of {{approvedAmount}}.": "You may request up to your Board Approved MHA Amount of {{approvedAmount}}.", "You may request up to your Board-approved MHA amount of {{approvedAmount}} combined.": "You may request up to your Board-approved MHA amount of {{approvedAmount}} combined.", "You need to create a list on MailChimp that {{appName}} can use for your newsletter.": "You need to create a list on MailChimp that {{appName}} can use for your newsletter.", - "Your {{combined}} Gross Requested Salary exceeds your {{combined}} Maximum Allowable Salary. Please make adjustments to your Salary Request above or fill out the Approval Process Section below to request a higher amount through our Progressive Approvals process. This may take {{timeframe}} as it needs to be signed off by the {{approver}}. This may affect your selected effective date.": "Your {{combined}} Gross Requested Salary exceeds your {{combined}} Maximum Allowable Salary. Please make adjustments to your Salary Request above or fill out the Approval Process Section below to request a higher amount through our Progressive Approvals process. This may take {{timeframe}} as it needs to be signed off by the {{approver}}. This may affect your selected effective date.", "Your contacts are now automatically syncing with MailChimp": "Your contacts are now automatically syncing with MailChimp", "Your MailChimp sync has been started. This process may take up to 4 hours to complete.": "Your MailChimp sync has been started. This process may take up to 4 hours to complete." } diff --git a/public/locales/es-419/translation.json b/public/locales/es-419/translation.json index 39b88b2bc8..c21079bef0 100644 --- a/public/locales/es-419/translation.json +++ b/public/locales/es-419/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " para poder volver a iniciar sesión con su correo electrónico oficial del ministerio.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " Aquí puede transferir fondos fácilmente entre su cuenta de personal, el Fondo de Ahorros del Personal y el Fondo de Ahorros para la Conferencia del Personal. Puede realizar una transferencia única o programar una transferencia mensual automática. Es muy fácil y totalmente autogestionable.", - "Expenses: ": "- Transferencias de salida: ", - "Expenses: {{transfersOut}}": "- Transferencias de salida: {{transfersOut}}", + "- Transfers out: ": "- Transferencias de salida: ", + "- Transfers out: {{transfersOut}}": "- Transferencias de salida: {{transfersOut}}", "-- All Active --": "-- Todos los elementos activos --", "-- All Hidden --": "--Todos Ocultos--", "-- None --": "-- Ninguno --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* Debes incluir nombre y apellido O nombre completo", "# Weeks": "# Weeks", "+ Add Mileage": "+ Añadir kilometraje", - "Income: ": "+ Traslados en: ", - "Income: {{transfersIn}}": "+ Transferencias en: {{transfersIn}}", + "+ Transfers in: ": "+ Traslados en: ", + "+ Transfers in: {{transfersIn}}": "+ Transferencias en: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>En casos especiales donde las solicitudes exceden el salario permitido restante, requerimos una revisión adicional a través de nuestras <2>Aprobaciones progresivas Proceso. Dependiendo de la solicitud, esto puede tardar hasta 14 días. <1>Alternativamente, puede descargar y enviar la <2>versión en papel de la solicitud de Salario Adicional si lo prefiere.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/fr-CA/translation.json b/public/locales/fr-CA/translation.json index e8415cf7ca..f87c671bd7 100644 --- a/public/locales/fr-CA/translation.json +++ b/public/locales/fr-CA/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": "afin de pouvoir vous reconnecter avec votre adresse e-mail officielle du ministère.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " Vous pouvez ainsi transférer facilement des fonds entre votre compte personnel, le Fonds d'épargne du personnel et le Fonds d'épargne de la Conférence du personnel. Vous pouvez effectuer un virement unique ou programmer un virement mensuel automatique. C'est très simple et entièrement en libre-service.", - "Expenses: ": "- Transferts sortants : ", - "Expenses: {{transfersOut}}": "- Transferts sortants : {{transfersOut}}", + "- Transfers out: ": "- Transferts sortants : ", + "- Transfers out: {{transfersOut}}": "- Transferts sortants : {{transfersOut}}", "-- All Active --": "-- Tous Actifs --", "-- All Hidden --": "-- Tout Masqué --", "-- None --": "-- Aucun --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* Vous devez inclure à la fois le prénom et le nom de famille OU le nom complet", "# Weeks": "# Weeks", "+ Add Mileage": "+ Ajouter du kilométrage", - "Income: ": "+ Transferts entrants : ", - "Income: {{transfersIn}}": "+ Transferts entrants : {{transfersIn}}", + "+ Transfers in: ": "+ Transferts entrants : ", + "+ Transfers in: {{transfersIn}}": "+ Transferts entrants : {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>Dans les cas particuliers où les demandes dépassent le salaire autorisé restant, nous exigeons un examen supplémentaire par le biais de nos <2>Approbations progressives Le traitement de votre demande peut prendre jusqu'à 14 jours, selon sa nature. <1>Vous pouvez également télécharger et soumettre la <2>version papier de la demande de salaire supplémentaire si vous préférez.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/fr-FR/translation.json b/public/locales/fr-FR/translation.json index aab27f0a69..1a043a472e 100644 --- a/public/locales/fr-FR/translation.json +++ b/public/locales/fr-FR/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": "afin de pouvoir vous reconnecter avec votre adresse e-mail officielle du ministère.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " Vous pouvez ainsi transférer facilement des fonds entre votre compte personnel, le Fonds d'épargne du personnel et le Fonds d'épargne de la Conférence du personnel. Vous pouvez effectuer un virement unique ou programmer un virement mensuel automatique. C'est très simple et entièrement en libre-service.", - "Expenses: ": "- Transferts sortants : ", - "Expenses: {{transfersOut}}": "- Transferts sortants : {{transfersOut}}", + "- Transfers out: ": "- Transferts sortants : ", + "- Transfers out: {{transfersOut}}": "- Transferts sortants : {{transfersOut}}", "-- All Active --": "-- Tous Actifs --", "-- All Hidden --": "-- Tout Masqué --", "-- None --": "-- Aucun --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* Vous devez inclure à la fois le prénom et le nom de famille OU le nom complet", "# Weeks": "# Weeks", "+ Add Mileage": "+ Ajouter du kilométrage", - "Income: ": "+ Transferts entrants : ", - "Income: {{transfersIn}}": "+ Transferts entrants : {{transfersIn}}", + "+ Transfers in: ": "+ Transferts entrants : ", + "+ Transfers in: {{transfersIn}}": "+ Transferts entrants : {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>Dans les cas particuliers où les demandes dépassent le salaire autorisé restant, nous exigeons un examen supplémentaire par le biais de nos <2>Approbations progressives Le traitement de votre demande peut prendre jusqu'à 14 jours, selon sa nature. <1>Vous pouvez également télécharger et soumettre la <2>version papier de la demande de salaire supplémentaire si vous préférez.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/fr/translation.json b/public/locales/fr/translation.json index 86dd717e20..b079eae393 100644 --- a/public/locales/fr/translation.json +++ b/public/locales/fr/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": "afin de pouvoir vous reconnecter avec votre adresse e-mail officielle du ministère.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " Vous pouvez ainsi transférer facilement des fonds entre votre compte personnel, le Fonds d'épargne du personnel et le Fonds d'épargne de la Conférence du personnel. Vous pouvez effectuer un virement unique ou programmer un virement mensuel automatique. C'est très simple et entièrement en libre-service.", - "Expenses: ": "- Transferts sortants : ", - "Expenses: {{transfersOut}}": "- Transferts sortants : {{transfersOut}}", + "- Transfers out: ": "- Transferts sortants : ", + "- Transfers out: {{transfersOut}}": "- Transferts sortants : {{transfersOut}}", "-- All Active --": "-- Tous Actifs --", "-- All Hidden --": "-- Tout Masqué --", "-- None --": "-- Aucun --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* Vous devez inclure à la fois le prénom et le nom de famille OU le nom complet", "# Weeks": "# Weeks", "+ Add Mileage": "+ Ajouter du kilométrage", - "Income: ": "+ Transferts entrants : ", - "Income: {{transfersIn}}": "+ Transferts entrants : {{transfersIn}}", + "+ Transfers in: ": "+ Transferts entrants : ", + "+ Transfers in: {{transfersIn}}": "+ Transferts entrants : {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>Dans les cas particuliers où les demandes dépassent le salaire autorisé restant, nous exigeons un examen supplémentaire par le biais de nos <2>Approbations progressives Le traitement de votre demande peut prendre jusqu'à 14 jours, selon sa nature. <1>Vous pouvez également télécharger et soumettre la <2>version papier de la demande de salaire supplémentaire si vous préférez.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/hy/translation.json b/public/locales/hy/translation.json index 671bf09dec..1b50578ec0 100644 --- a/public/locales/hy/translation.json +++ b/public/locales/hy/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " որպեսզի կարողանաք նորից մուտք գործել ձեր պաշտոնական նախարարության էլ. փոստով։", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " որտեղ դուք կարող եք անխափան կերպով փոխանցել միջոցներ ձեր աշխատակազմի հաշվի, Աշխատակազմի խնայողական ֆոնդի և Աշխատակազմի համաժողովի խնայողական ֆոնդի միջև: Դուք կարող եք կատարել մեկանգամյա փոխանցում կամ պլանավորել ավտոմատ ամսական փոխանցում: Այն իսկապես հեշտ է և ամբողջությամբ ինքնասպասարկմամբ:", - "Expenses: ": "- Տրանսֆերներ դուրս: ", - "Expenses: {{transfersOut}}": "- Ելքային փոխանցումներ՝ {{transfersOut}}", + "- Transfers out: ": "- Տրանսֆերներ դուրս: ", + "- Transfers out: {{transfersOut}}": "- Ելքային փոխանցումներ՝ {{transfersOut}}", "-- All Active --": "-- Բոլոր ակտիվները --", "-- All Hidden --": "-- Բոլորը թաքնված --", "-- None --": "-- Ոչ մեկը--", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* Դուք պետք է նշեք և՛ անունը, և՛ ազգանունը, կամ՛ լրիվ անունը", "# Weeks": "# Weeks", "+ Add Mileage": "+ Ավելացնել վազքը", - "Income: ": "+ Փոխանցումներ՝ ", - "Income: {{transfersIn}}": "+ Փոխանցումներ՝ {{transfersIn}}", + "+ Transfers in: ": "+ Փոխանցումներ՝ ", + "+ Transfers in: {{transfersIn}}": "+ Փոխանցումներ՝ {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>Հատուկ դեպքերում, երբ պահանջները գերազանցում են մնացած թույլատրելի աշխատավարձը, մենք պահանջում ենք լրացուցիչ վերանայում մեր <2>Աստիճանական հաստատումների միջոցով գործընթաց։ Կախված հարցումից, սա կարող է տևել մինչև 14 օր։ <1>Այլընտրանքորեն, կարող եք ներբեռնել և ներկայացնել <2>թղթային տարբերակը լրացուցիչ աշխատավարձի հայտի մասին, եթե նախընտրում եք։", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/id/translation.json b/public/locales/id/translation.json index d939794569..e756217901 100644 --- a/public/locales/id/translation.json +++ b/public/locales/id/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " sehingga Anda dapat masuk kembali dengan email resmi kementerian Anda.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " Di sini, Anda dapat dengan mudah memindahkan dana antara rekening staf, Dana Tabungan Staf, dan Dana Tabungan Konferensi Staf. Anda dapat melakukan transfer satu kali atau menjadwalkan transfer bulanan otomatis. Prosesnya sangat mudah dan sepenuhnya mandiri.", - "Expenses: ": "- Transfer keluar: ", - "Expenses: {{transfersOut}}": "- Transfer keluar: {{transfersOut}}", + "- Transfers out: ": "- Transfer keluar: ", + "- Transfers out: {{transfersOut}}": "- Transfer keluar: {{transfersOut}}", "-- All Active --": "Aktif Semua", "-- All Hidden --": "-- Semua Tersembunyi --", "-- None --": "-- Tidak ada --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* Anda perlu menyertakan Nama Depan & Belakang ATAU Nama Lengkap", "# Weeks": "# Weeks", "+ Add Mileage": "+ Tambahkan Jarak Tempuh", - "Income: ": "+ Transfer dalam: ", - "Income: {{transfersIn}}": "+ Transfer masuk: {{transfersIn}}", + "+ Transfers in: ": "+ Transfer dalam: ", + "+ Transfers in: {{transfersIn}}": "+ Transfer masuk: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>Dalam kasus khusus di mana permintaan melebihi sisa gaji yang diizinkan, kami memerlukan tinjauan tambahan melalui <2>Persetujuan Progresif kami Proses ini dapat memakan waktu hingga 14 hari, tergantung permintaan. <1>Atau, Anda dapat mengunduh dan mengirimkan <2>versi kertas permintaan Gaji Tambahan jika Anda mau.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/it/translation.json b/public/locales/it/translation.json index 83f25ea9e0..117b8dfcbd 100644 --- a/public/locales/it/translation.json +++ b/public/locales/it/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " così potrai accedere nuovamente con la tua email ufficiale del ministero.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " dove puoi trasferire fondi senza problemi tra il tuo conto personale, il Fondo di Risparmio del Personale e il Fondo di Risparmio per le Conferenze del Personale. Puoi effettuare un trasferimento una tantum o programmare un trasferimento mensile automatico. È davvero semplice e completamente self-service.", - "Expenses: ": "- Trasferimenti in uscita: ", - "Expenses: {{transfersOut}}": "- Trasferimenti in uscita: {{transfersOut}}", + "- Transfers out: ": "- Trasferimenti in uscita: ", + "- Transfers out: {{transfersOut}}": "- Trasferimenti in uscita: {{transfersOut}}", "-- All Active --": "-- Tutti attivi --", "-- All Hidden --": "-- Tutto nascosto --", "-- None --": "-- Nessuno --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* È necessario includere sia il nome che il cognome OPPURE il nome completo", "# Weeks": "# Weeks", "+ Add Mileage": "+ Aggiungi chilometraggio", - "Income: ": "+ Trasferimenti in: ", - "Income: {{transfersIn}}": "+ Trasferimenti in: {{transfersIn}}", + "+ Transfers in: ": "+ Trasferimenti in: ", + "+ Transfers in: {{transfersIn}}": "+ Trasferimenti in: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>In casi particolari in cui le richieste superano lo stipendio residuo consentito, richiediamo un'ulteriore revisione tramite il nostro <2>sistema di Approvazioni Progressive processo. A seconda della richiesta, questo può richiedere fino a 14 giorni. <1>In alternativa, puoi scaricare e inviare la <2>versione cartacea della richiesta di stipendio aggiuntivo, se preferisci.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/ko/translation.json b/public/locales/ko/translation.json index dce46a6f21..46c31cff93 100644 --- a/public/locales/ko/translation.json +++ b/public/locales/ko/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " 그러면 공식 사역 이메일로 다시 로그인할 수 있습니다.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " 직원 계좌, 직원 저축 기금, 직원 컨퍼런스 저축 기금 간에 원활하게 자금을 이체할 수 있습니다. 일시불 이체나 월별 자동 이체 예약을 할 수 있습니다. 정말 간편하고 셀프 서비스로 간편하게 이용하실 수 있습니다.", - "Expenses: ": "- 전송: ", - "Expenses: {{transfersOut}}": "- 전송: {{transfersOut}}", + "- Transfers out: ": "- 전송: ", + "- Transfers out: {{transfersOut}}": "- 전송: {{transfersOut}}", "-- All Active --": "-- 모든 활동 중 --", "-- All Hidden --": "-- 모두 숨김 --", "-- None --": "-- 없음 --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* 성과 이름 또는 전체 이름을 모두 포함해야 합니다.", "# Weeks": "# Weeks", "+ Add Mileage": "+ 마일리지 추가", - "Income: ": "+ 전송: ", - "Income: {{transfersIn}}": "+ 전송: {{transfersIn}}", + "+ Transfers in: ": "+ 전송: ", + "+ Transfers in: {{transfersIn}}": "+ 전송: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>요청이 남은 허용 급여를 초과하는 특별한 경우, <2>진행 승인을 통해 추가 검토가 필요합니다. 처리 과정입니다. 요청에 따라 최대 14일이 소요될 수 있습니다. <1>또는 <2>종이 버전을 다운로드하여 제출할 수 있습니다. 원하시면 추가 급여 요청을 해주세요.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/my/translation.json b/public/locales/my/translation.json index 95623e89af..560b06c686 100644 --- a/public/locales/my/translation.json +++ b/public/locales/my/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " သို့မှသာ သင်၏တရားဝင်ဝန်ကြီးဌာနအီးမေးလ်ဖြင့် ပြန်လည်ဝင်ရောက်နိုင်ပါသည်။", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " သင့်ဝန်ထမ်းအကောင့်၊ Staff Savings Fund နှင့် Staff Conference Savings Fund များကြားတွင် ရံပုံငွေများကို ချောမွေ့စွာ ရွှေ့ပြောင်းနိုင်ပါသည်။ သင်သည် တစ်ကြိမ် လွှဲပြောင်းခြင်း သို့မဟုတ် အလိုအလျောက် လစဉ် လွှဲပြောင်းမှုကို အချိန်ဇယားဆွဲနိုင်သည်။ ဒါဟာတကယ်ကိုလွယ်ကူပြီးအားလုံးကိုယ်ပိုင်ဝန်ဆောင်မှုဖြစ်ပါတယ်။", - "Expenses: ": "- လွှဲပြောင်းမှုများ: ", - "Expenses: {{transfersOut}}": "- လွှဲပြောင်းမှုများ- {{transfersOut}}", + "- Transfers out: ": "- လွှဲပြောင်းမှုများ: ", + "- Transfers out: {{transfersOut}}": "- လွှဲပြောင်းမှုများ- {{transfersOut}}", "-- All Active --": "-- အားလုံး Active --", "-- All Hidden --": "-- အားလုံးကို ဝှက်ထားသည် --", "-- None --": "-- မရှိ --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* သင်သည် ပထမအမည်နှင့် နောက်ဆုံးအမည် သို့မဟုတ် အမည်အပြည့်အစုံ နှစ်ခုလုံး ပါဝင်ရန် လိုအပ်သည်။", "# Weeks": "# Weeks", "+ Add Mileage": "+ မိုင်တိုင်ထည့်ပါ။", - "Income: ": "+ လွှဲပြောင်းမှုများ- ", - "Income: {{transfersIn}}": "+ လွှဲပြောင်းမှုများ- {{transfersIn}}", + "+ Transfers in: ": "+ လွှဲပြောင်းမှုများ- ", + "+ Transfers in: {{transfersIn}}": "+ လွှဲပြောင်းမှုများ- {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>တောင်းဆိုမှုများသည် ကျန်ခွင့်ပြုထားသောလစာထက် ကျော်လွန်နေသည့် အထူးကိစ္စများတွင်၊ ကျွန်ုပ်တို့၏ <2>တိုးတက်သောခွင့်ပြုချက်များမှတစ်ဆင့် ထပ်လောင်းသုံးသပ်ရန် လိုအပ်ပါသည်။ လုပ်ငန်းစဉ်။ တောင်းဆိုမှုပေါ်မူတည်၍ ၎င်းသည် 14 ရက်အထိ ကြာနိုင်သည်။ <1>တနည်းအားဖြင့် သင်သည် <2>စက္ကူဗားရှင်းကို ဒေါင်းလုဒ်လုပ်ပြီး တင်သွင်းနိုင်သည်။ လိုချင်ရင် အပိုလစာ တောင်းခံမှု။", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/nl-NL/translation.json b/public/locales/nl-NL/translation.json index 850e23dc00..9e7aa02bed 100644 --- a/public/locales/nl-NL/translation.json +++ b/public/locales/nl-NL/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " zodat u opnieuw kunt inloggen met uw officiële e-mailadres van het ministerie.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " Waar u naadloos geld kunt overmaken tussen uw personeelsrekening, het spaarfonds voor personeel en het spaarfonds voor personeelsconferenties. U kunt een eenmalige overboeking doen of een automatische maandelijkse overboeking plannen. Het is heel eenvoudig en volledig selfservice.", - "Expenses: ": "- Uitgaande overschrijvingen: ", - "Expenses: {{transfersOut}}": "- Uitgaande transfers: {{transfersOut}}", + "- Transfers out: ": "- Uitgaande overschrijvingen: ", + "- Transfers out: {{transfersOut}}": "- Uitgaande transfers: {{transfersOut}}", "-- All Active --": "- Alle Actieve -", "-- All Hidden --": "--Alles verborgen--", "-- None --": "-- Geen --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* U moet zowel uw voor- als achternaam OF uw volledige naam opgeven", "# Weeks": "# Weeks", "+ Add Mileage": "+ Kilometerstand toevoegen", - "Income: ": "+ Overboekingen in: ", - "Income: {{transfersIn}}": "+ Overboekingen in: {{transfersIn}}", + "+ Transfers in: ": "+ Overboekingen in: ", + "+ Transfers in: {{transfersIn}}": "+ Overboekingen in: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>In bijzondere gevallen waarin de verzoeken het resterende toegestane salaris overschrijden, vereisen we een aanvullende beoordeling via onze <2>Progressieve Goedkeuringen verwerking. Afhankelijk van de aanvraag kan dit tot 14 dagen duren. <1>Als alternatief kunt u de <2>papieren versie downloaden en indienen van het Aanvullend Salaris verzoek indien u dat wenst.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/pl/translation.json b/public/locales/pl/translation.json index 8c6fe93845..394e67e7a9 100644 --- a/public/locales/pl/translation.json +++ b/public/locales/pl/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " abyś mógł zalogować się ponownie, używając oficjalnego adresu e-mail swojego ministerstwa.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " gdzie możesz bezproblemowo przesyłać środki między kontem pracowniczym, Funduszem Oszczędnościowym Pracowników i Funduszem Oszczędnościowym Konferencji Pracowniczych. Możesz wykonać jednorazowy przelew lub zaplanować automatyczny przelew comiesięczny. To naprawdę proste i samoobsługowe.", - "Expenses: ": "- Transfery wychodzące: ", - "Expenses: {{transfersOut}}": "- Transfery wychodzące: {{transfersOut}}", + "- Transfers out: ": "- Transfery wychodzące: ", + "- Transfers out: {{transfersOut}}": "- Transfery wychodzące: {{transfersOut}}", "-- All Active --": "-- Aktywne --", "-- All Hidden --": "-- Wszystko ukryte --", "-- None --": "-- Żaden --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* Należy podać imię i nazwisko LUB imię i nazwisko", "# Weeks": "# Weeks", "+ Add Mileage": "+ Dodaj przebieg", - "Income: ": "+ Przelewy w: ", - "Income: {{transfersIn}}": "+ Transfery w: {{transfersIn}}", + "+ Transfers in: ": "+ Przelewy w: ", + "+ Transfers in: {{transfersIn}}": "+ Transfery w: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>W szczególnych przypadkach, gdy żądania przekraczają pozostałą dopuszczalną kwotę wynagrodzenia, wymagamy dodatkowej analizy w ramach naszego <2>Progressive Approvals Proces. W zależności od wniosku, może to potrwać do 14 dni. <1>Alternatywnie możesz pobrać i przesłać <2>wersję papierową wniosku o dodatkowe wynagrodzenie, jeśli wolisz.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/pt-BR/translation.json b/public/locales/pt-BR/translation.json index 2d9e05fadf..8a1ccb0e65 100644 --- a/public/locales/pt-BR/translation.json +++ b/public/locales/pt-BR/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " para que você possa fazer login novamente com seu e-mail oficial do ministério.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " onde você pode transferir fundos facilmente entre sua conta de funcionário, o Fundo de Poupança para Funcionários e o Fundo de Poupança para Conferências de Funcionários. Você pode fazer uma transferência única ou agendar uma transferência mensal automática. É muito fácil e totalmente self-service.", - "Expenses: ": "- Transferências de saída: ", - "Expenses: {{transfersOut}}": "- Transferências de saída: {{transfersOut}}", + "- Transfers out: ": "- Transferências de saída: ", + "- Transfers out: {{transfersOut}}": "- Transferências de saída: {{transfersOut}}", "-- All Active --": "--Todos Ativos--", "-- All Hidden --": "-- Tudo escondido --", "-- None --": "-- Nenhum --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* Você precisa incluir o primeiro e o último nome OU o nome completo", "# Weeks": "# Weeks", "+ Add Mileage": "+ Adicionar Quilometragem", - "Income: ": "+ Transferências de entrada: ", - "Income: {{transfersIn}}": "+ Transferências em: {{transfersIn}}", + "+ Transfers in: ": "+ Transferências de entrada: ", + "+ Transfers in: {{transfersIn}}": "+ Transferências em: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>Em casos especiais em que as solicitações excedam o salário permitido restante, exigimos uma análise adicional por meio de nossas <2>Aprovações Progressivas O processo, dependendo da solicitação, pode levar até 14 dias. <1>Alternativamente, você pode baixar e enviar a <2>versão impressa da solicitação de salário adicional, se preferir.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/ro/translation.json b/public/locales/ro/translation.json index 1ba4823dcc..843e870e23 100644 --- a/public/locales/ro/translation.json +++ b/public/locales/ro/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " ca să te poți conecta din nou cu adresa ta de e-mail oficială a ministerului.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " unde puteți transfera fără probleme fonduri între contul dvs. de personal, Fondul de Economii pentru Personal și Fondul de Economii pentru Conferințe de Personal. Puteți face un transfer unic sau puteți programa un transfer lunar automat. Este foarte ușor și totul se face în regim self-service.", - "Expenses: ": "- Transferuri în afara: ", - "Expenses: {{transfersOut}}": "- Transferuri în afara: {{transfersOut}}", + "- Transfers out: ": "- Transferuri în afara: ", + "- Transfers out: {{transfersOut}}": "- Transferuri în afara: {{transfersOut}}", "-- All Active --": "-- Toate active --", "-- All Hidden --": "-- Toate ascunse --", "-- None --": "-- Niciunul --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* Trebuie să includeți atât prenumele, cât și numele de familie SAU numele complet", "# Weeks": "# Weeks", "+ Add Mileage": "+ Adăugați kilometraj", - "Income: ": "+ Transferuri în: ", - "Income: {{transfersIn}}": "+ Transferuri în: {{transfersIn}}", + "+ Transfers in: ": "+ Transferuri în: ", + "+ Transfers in: {{transfersIn}}": "+ Transferuri în: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>În cazuri speciale în care solicitările depășesc salariul admis rămas, solicităm o analiză suplimentară prin intermediul <2>Aprobărilor progresive proces. În funcție de solicitare, acest lucru poate dura până la 14 zile. <1>Alternativ, puteți descărca și trimite <2>versiunea pe hârtie a cererii de Salariu suplimentar, dacă preferați.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/ru/translation.json b/public/locales/ru/translation.json index c603eeeeb8..5445a220a1 100644 --- a/public/locales/ru/translation.json +++ b/public/locales/ru/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " чтобы входить с помощью эл. почты вашего служения.", " to request changes.": " запросить изменения.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " где вы можете легко перемещать средства между своим счётом сотрудника, Фондом сбережений для сотрудников и Фондом сбережений для конференций сотрудников. Вы можете сделать разовый перевод или настроить автоматический ежемесячный перевод. Это очень просто и полностью автоматизировано.", - "Expenses: ": "- Переводы из: ", - "Expenses: {{transfersOut}}": "- Переводы из: {{transfersOut}}", + "- Transfers out: ": "- Переводы из: ", + "- Transfers out: {{transfersOut}}": "- Переводы из: {{transfersOut}}", "-- All Active --": "-- Все активные --", "-- All Hidden --": "-- Все скрытые --", "-- None --": "-- Нет --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* Укажите имя и фамилию или полное имя", "# Weeks": "# Недели", "+ Add Mileage": "+ Добавить пробег", - "Income: ": "+ Переводы в: ", - "Income: {{transfersIn}}": "+ Переводы в: {{transfersIn}}", + "+ Transfers in: ": "+ Переводы в: ", + "+ Transfers in: {{transfersIn}}": "+ Переводы в: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>В особых случаях, когда запросы превышают оставшуюся допустимую заработную плату, мы требуем дополнительного рассмотрения через нашу <2>Прогрессивную систему утверждения. Процесс. В зависимости от запроса, это может занять до 14 дней. <1>В качестве альтернативы вы можете загрузить и отправить <2>бумажную версию запроса на дополнительную зарплату, если вы предпочитаете.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/th/translation.json b/public/locales/th/translation.json index 171e60979e..cf8e7a90e9 100644 --- a/public/locales/th/translation.json +++ b/public/locales/th/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " เพื่อให้คุณสามารถเข้าสู่ระบบอีกครั้งด้วยอีเมลกระทรวงอย่างเป็นทางการของคุณได้", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " ที่ซึ่งคุณสามารถโอนเงินระหว่างบัญชีพนักงาน กองทุนออมทรัพย์พนักงาน และกองทุนออมทรัพย์การประชุมพนักงานได้อย่างราบรื่น คุณสามารถโอนเงินครั้งเดียวหรือตั้งเวลาโอนอัตโนมัติรายเดือนก็ได้ สะดวกและรวดเร็วด้วยบริการตนเอง", - "Expenses: ": "- โอนออก : ", - "Expenses: {{transfersOut}}": "- โอนออก: {{transfersOut}}", + "- Transfers out: ": "- โอนออก : ", + "- Transfers out: {{transfersOut}}": "- โอนออก: {{transfersOut}}", "-- All Active --": "-- ทั้งหมดที่ใช้งานอยู่ --", "-- All Hidden --": "-- ซ่อนทั้งหมด --", "-- None --": "-- ไม่มี --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* คุณต้องใส่ทั้งชื่อและนามสกุล หรือชื่อเต็ม", "# Weeks": "# Weeks", "+ Add Mileage": "+ เพิ่มระยะทาง", - "Income: ": "+ โอนเข้า : ", - "Income: {{transfersIn}}": "+ โอนเข้า: {{transfersIn}}", + "+ Transfers in: ": "+ โอนเข้า : ", + "+ Transfers in: {{transfersIn}}": "+ โอนเข้า: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>ในกรณีพิเศษที่คำขอเกินเงินเดือนที่เหลือที่อนุญาต เราจำเป็นต้องมีการตรวจสอบเพิ่มเติมผ่าน <2>การอนุมัติแบบก้าวหน้าของเรา กระบวนการ ขึ้นอยู่กับคำขอ อาจใช้เวลาถึง 14 วัน <1>หรือคุณอาจดาวน์โหลดและส่ง <2>เวอร์ชันกระดาษ ของการร้องขอเงินเดือนเพิ่มเติมหากคุณต้องการ", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/tr/translation.json b/public/locales/tr/translation.json index ff6891319f..7ec3081ced 100644 --- a/public/locales/tr/translation.json +++ b/public/locales/tr/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " resmi hizmet e-postanızla tekrar oturum açabilmeniz için", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " Personel hesabınız, Personel Tasarruf Fonu ve Personel Konferansı Tasarruf Fonu arasında sorunsuz bir şekilde para transferi yapabileceğiniz bir platform. Tek seferlik transfer yapabilir veya otomatik aylık transfer planlayabilirsiniz. Gerçekten çok kolay ve tamamen self servis.", - "Expenses: ": "- Dışarı transferler: ", - "Expenses: {{transfersOut}}": "- Dışarı transferler: {{transfersOut}}", + "- Transfers out: ": "- Dışarı transferler: ", + "- Transfers out: {{transfersOut}}": "- Dışarı transferler: {{transfersOut}}", "-- All Active --": "-- Tümü Etkin --", "-- All Hidden --": "-- Tümü Gizli --", "-- None --": "-- Hiçbiri --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* Hem Adınızı hem de Soyadınızı ya da Tam Adınızı belirtmeniz gerekiyor", "# Weeks": "# Weeks", "+ Add Mileage": "+ Kilometre Ekle", - "Income: ": "+ Transferler: ", - "Income: {{transfersIn}}": "+ Transferler: {{transfersIn}}", + "+ Transfers in: ": "+ Transferler: ", + "+ Transfers in: {{transfersIn}}": "+ Transferler: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>Özel durumlarda taleplerin kalan izin verilen maaşı aşması durumunda, <2>İlerici Onayımız aracılığıyla ek inceleme yapılmasını talep ediyoruz İşlem süresi talebe bağlı olarak 14 güne kadar uzayabilir. <1>Alternatif olarak, makalenin kağıt versiyonunu indirip gönderebilirsiniz Eğer tercih ederseniz Ek Maaş talebinizi iletebilirsiniz.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/uk/translation.json b/public/locales/uk/translation.json index 6efba35638..d0e79f5910 100644 --- a/public/locales/uk/translation.json +++ b/public/locales/uk/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " щоб ви могли знову увійти, використовуючи свою офіційну електронну адресу міністерства.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " де ви можете безперешкодно переказувати кошти між вашим рахунком для персоналу, Фондом заощаджень для персоналу та Фондом заощаджень для конференцій персоналу. Ви можете зробити одноразовий переказ або запланувати автоматичний щомісячний переказ. Це дуже просто та повністю самообслуговується.", - "Expenses: ": "- Вихідні перекази: ", - "Expenses: {{transfersOut}}": "- Вихідні перекази: {{transfersOut}}", + "- Transfers out: ": "- Вихідні перекази: ", + "- Transfers out: {{transfersOut}}": "- Вихідні перекази: {{transfersOut}}", "-- All Active --": "-- Всі активні --", "-- All Hidden --": "-- Усі приховані --", "-- None --": "-- Жоден --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* Вам потрібно вказати ім'я та прізвище АБО повне ім'я", "# Weeks": "# Weeks", "+ Add Mileage": "+ Додати пробіг", - "Income: ": "+ Перекази в: ", - "Income: {{transfersIn}}": "+ Перекази в: {{transfersIn}}", + "+ Transfers in: ": "+ Перекази в: ", + "+ Transfers in: {{transfersIn}}": "+ Перекази в: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>В особливих випадках, коли запити перевищують залишок допустимої зарплати, ми вимагаємо додаткового розгляду через наші <2>Прогресивні схвалення процес. Залежно від запиту, це може тривати до 14 днів. <1>Або ж ви можете завантажити та надіслати <2>паперову версію запиту на додаткову зарплату, якщо бажаєте.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/vi/translation.json b/public/locales/vi/translation.json index 66024afef8..c477147192 100644 --- a/public/locales/vi/translation.json +++ b/public/locales/vi/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " để bạn có thể đăng nhập lại bằng email chính thức của bộ.", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " nơi bạn có thể dễ dàng chuyển tiền giữa tài khoản nhân viên, Quỹ Tiết kiệm Nhân viên và Quỹ Tiết kiệm Hội nghị Nhân viên. Bạn có thể thực hiện chuyển khoản một lần hoặc lên lịch chuyển khoản tự động hàng tháng. Thật dễ dàng và hoàn toàn tự phục vụ.", - "Expenses: ": "- Chuyển ra: ", - "Expenses: {{transfersOut}}": "- Chuyển ra: {{transfersOut}}", + "- Transfers out: ": "- Chuyển ra: ", + "- Transfers out: {{transfersOut}}": "- Chuyển ra: {{transfersOut}}", "-- All Active --": "-- Tất cả đều hoạt động --", "-- All Hidden --": "-- Tất cả đều ẩn --", "-- None --": "-- Không có --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* Bạn cần phải bao gồm cả Tên & Họ HOẶC Họ và tên đầy đủ", "# Weeks": "# Weeks", "+ Add Mileage": "+ Thêm số dặm", - "Income: ": "+ Chuyển khoản vào: ", - "Income: {{transfersIn}}": "+ Chuyển vào: {{transfersIn}}", + "+ Transfers in: ": "+ Chuyển khoản vào: ", + "+ Transfers in: {{transfersIn}}": "+ Chuyển vào: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>Trong những trường hợp đặc biệt khi yêu cầu vượt quá mức lương còn lại được phép, chúng tôi yêu cầu xem xét bổ sung thông qua <2>Phê duyệt lũy tiến quá trình. Tùy thuộc vào yêu cầu, quá trình này có thể mất tới 14 ngày. <1>Ngoài ra, bạn có thể tải xuống và nộp <2>phiên bản giấy của yêu cầu Lương bổ sung nếu bạn muốn.", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/public/locales/zh-Hans-CN/translation.json b/public/locales/zh-Hans-CN/translation.json index a30014ef1d..24adda5530 100644 --- a/public/locales/zh-Hans-CN/translation.json +++ b/public/locales/zh-Hans-CN/translation.json @@ -12,8 +12,8 @@ " so you can log back in with your official ministry email.": " 这样您就可以使用您的官方部门电子邮件重新登录。", " to request changes.": " to request changes.", " where you can seamlessly move funds between your staff account, the Staff Savings Fund, and the Staff Conference Savings Fund. You can make a one-time transfer or schedule an automatic monthly transfer. It's really easy and all self service.": " 您可以在员工账户、员工储蓄基金和员工会议储蓄基金之间无缝转移资金。您可以进行一次性转账,也可以设置每月自动转账。操作非常简单,而且完全自助。", - "Expenses: ": "- 转出: ", - "Expenses: {{transfersOut}}": "- 转出: {{transfersOut}}", + "- Transfers out: ": "- 转出: ", + "- Transfers out: {{transfersOut}}": "- 转出: {{transfersOut}}", "-- All Active --": "-- 全部活跃的 --", "-- All Hidden --": "-- 全部隐藏 --", "-- None --": "-- 无 --", @@ -266,8 +266,8 @@ "* You need to include both First & Last Name OR Full Name": "* 您需要同时包含名字和姓氏或全名", "# Weeks": "# Weeks", "+ Add Mileage": "+ 添加里程", - "Income: ": "+ 转入: ", - "Income: {{transfersIn}}": "+ 转入: {{transfersIn}}", + "+ Transfers in: ": "+ 转入: ", + "+ Transfers in: {{transfersIn}}": "+ 转入: {{transfersIn}}", "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)": "<0><0>Note: Taxes and any requested 403(b) amount will be subtracted from the amount of additional salary that you are requesting. The percentage of taxes on this request should be similar to that of your paychecks, but may be more if you have chosen to have an additional amount of withholding on your paychecks. If you have any questions about this, please call 1 (888) 278-7233 (option 2, 2)", "<0>In special cases where requests exceed the remaining allowable salary, we require additional review through our <2>Progressive Approvals process. Depending on the request, this can take up to 14 days.<1>Alternatively, you may download and submit the <2>paper version of the Additional Salary request if you prefer.": "<0>在申请金额超过剩余允许薪资的特殊情况下,我们需要通过我们的<2>渐进式审批流程进行额外审核。处理流程。根据具体申请情况,这最多可能需要14天。 <1>或者,您可以下载并提交<2>纸质版本如果您愿意,可以申请额外薪资。", "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org": "<0>Note: If any of the above information is not correct, please contact HR Services with the correct information at <3>(888) 278-7233 or <6>(407) 826-2287. Email: <9>--@cru.org", diff --git a/src/components/Contacts/ContactDetails/ContactDetailsTab/Other/EditContactOtherModal/EditContactOtherModal.test.tsx b/src/components/Contacts/ContactDetails/ContactDetailsTab/Other/EditContactOtherModal/EditContactOtherModal.test.tsx index 1bc45c336b..f5eee3db3c 100644 --- a/src/components/Contacts/ContactDetails/ContactDetailsTab/Other/EditContactOtherModal/EditContactOtherModal.test.tsx +++ b/src/components/Contacts/ContactDetails/ContactDetailsTab/Other/EditContactOtherModal/EditContactOtherModal.test.tsx @@ -492,115 +492,8 @@ describe('EditContactOtherModal', () => { userId: 'user-2', greeting: newGreeting, envelopeGreeting: newEnvelopeGreeting, - contactReferralsToMe: [], + contactReferralsToMe: [{}], }, }); }); - - it('does not re-create the referral when the referred by is unchanged', async () => { - const mutationSpy = jest.fn(); - const { getByLabelText, getByText } = render( - - - - - onCall={mutationSpy} - > - - - - - - - , - ); - - // Edit a field other than the Connecting Partner and save - const website = getByLabelText('Website'); - userEvent.clear(website); - userEvent.type(website, 'unchanged-referral.com'); - userEvent.click(getByText('Save')); - - await waitFor(() => - expect(mockEnqueue).toHaveBeenCalledWith('Contact updated successfully', { - variant: 'success', - }), - ); - - const updateCall = mutationSpy.mock.calls - .map(([{ operation }]) => operation) - .find(({ operationName }) => operationName === 'UpdateContactOther'); - - // The existing referral must not be re-submitted, otherwise the API's - // nested-attributes handling creates a duplicate ContactReferral row. - expect(updateCall?.variables.attributes.contactReferralsToMe).toEqual([]); - }); - - it('destroys the old referral and creates the new one when the referred by changes', async () => { - const mutationSpy = jest.fn(); - const { getByRole, getByText } = render( - - - - - onCall={mutationSpy} - mocks={{ - ContactOptions: { - contacts: { - nodes: [{ id: 'new-partner', name: 'Aaa Bbb' }], - }, - }, - }} - > - - - - - - - , - ); - - const referredByElement = getByRole('combobox', { - hidden: true, - name: 'Connecting Partner', - }); - userEvent.click(referredByElement); - userEvent.type(referredByElement, 'Aa'); - await waitFor(() => expect(getByText('Aaa Bbb')).toBeInTheDocument()); - userEvent.click(getByText('Aaa Bbb')); - userEvent.click(getByText('Save')); - - await waitFor(() => - expect(mockEnqueue).toHaveBeenCalledWith('Contact updated successfully', { - variant: 'success', - }), - ); - - const updateCall = mutationSpy.mock.calls - .map(([{ operation }]) => operation) - .find(({ operationName }) => operationName === 'UpdateContactOther'); - - expect(updateCall?.variables.attributes.contactReferralsToMe).toEqual([ - { id: referral.id, destroy: true }, - { referredById: 'new-partner' }, - ]); - }); }); diff --git a/src/components/Contacts/ContactDetails/ContactDetailsTab/Other/EditContactOtherModal/EditContactOtherModal.tsx b/src/components/Contacts/ContactDetails/ContactDetailsTab/Other/EditContactOtherModal/EditContactOtherModal.tsx index 1da7e0f0ce..4fce6ac9cb 100644 --- a/src/components/Contacts/ContactDetails/ContactDetailsTab/Other/EditContactOtherModal/EditContactOtherModal.tsx +++ b/src/components/Contacts/ContactDetails/ContactDetailsTab/Other/EditContactOtherModal/EditContactOtherModal.tsx @@ -197,14 +197,23 @@ export const EditContactOtherModal: React.FC = ({ attributes: ContactUpdateInput & { referredById: string }, ) => { const referralsInput = - referral?.referredBy.id === selectedId - ? // No changes - [] - : // Remove the old referral and add the new referral (if any) - [ - ...(referral ? [{ id: referral.id, destroy: true }] : []), - ...(selectedId ? [{ referredById: selectedId }] : []), - ]; + referral && referral.referredBy.id !== selectedId + ? [ + { + id: referral.id, + destroy: true, + }, + { + referredById: attributes.referredById, + }, + ] + : selectedId + ? [ + { + referredById: attributes.referredById, + }, + ] + : [{}]; await updateContactOther({ variables: { accountListId, diff --git a/src/components/Contacts/ContactDetails/ContactReferralTab/ContactReferralTab.graphql b/src/components/Contacts/ContactDetails/ContactReferralTab/ContactReferralTab.graphql index c1cd5eed10..0cbdbfc266 100644 --- a/src/components/Contacts/ContactDetails/ContactReferralTab/ContactReferralTab.graphql +++ b/src/components/Contacts/ContactDetails/ContactReferralTab/ContactReferralTab.graphql @@ -22,23 +22,3 @@ fragment ContactReferral on Referral { name } } - -mutation DeleteContactReferral( - $accountListId: ID! - $contactId: ID! - $referralId: ID! -) { - updateContact( - input: { - accountListId: $accountListId - attributes: { - id: $contactId - contactReferralsByMe: [{ id: $referralId, destroy: true }] - } - } - ) { - contact { - id - } - } -} diff --git a/src/components/Contacts/ContactDetails/ContactReferralTab/ContactReferralTab.test.tsx b/src/components/Contacts/ContactDetails/ContactReferralTab/ContactReferralTab.test.tsx index 2cf4cc9dbc..b086ad51ba 100644 --- a/src/components/Contacts/ContactDetails/ContactReferralTab/ContactReferralTab.test.tsx +++ b/src/components/Contacts/ContactDetails/ContactReferralTab/ContactReferralTab.test.tsx @@ -1,19 +1,10 @@ import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { ApolloErgonoMockMap } from 'graphql-ergonomock'; -import { SnackbarProvider } from 'notistack'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import { render } from '__tests__/util/testingLibraryReactMock'; import { ContactPanelProvider } from 'src/components/Shared/ContactPanelProvider/ContactPanelProvider'; -import theme from 'src/theme'; import { ContactReferralTab } from './ContactReferralTab'; -import { - ContactReferralTabQuery, - DeleteContactReferralMutation, -} from './ContactReferralTab.generated'; +import { ContactReferralTabQuery } from './ContactReferralTab.generated'; const accountListId = 'accountListId'; const contactId = 'contactId'; @@ -25,67 +16,22 @@ const router = { pathname: '/accountLists/[accountListId]/contacts/[[...contactId]]', }; -const pageInfo = { hasNextPage: false, endCursor: null }; - -const referralNode = { - id: 'referral-id', - createdAt: '2021-04-29T07:48:28+0000', - referredTo: { - id: 'contact-id-2', - name: 'name-2', - }, -}; - -const secondReferralNode = { - id: 'referral-id-2', - createdAt: '2021-05-01T07:48:28+0000', - referredTo: { - id: 'contact-id-3', - name: 'name-3', - }, -}; - -type Mocks = { - ContactReferralTab: ContactReferralTabQuery; - DeleteContactReferral: DeleteContactReferralMutation; -}; - -type ReferralMockNode = { - id: string; - createdAt: string; - referredTo: { id: string; name: string }; -}; - -const buildMocks = (nodes: ReferralMockNode[]): ApolloErgonoMockMap => ({ - ContactReferralTab: { - contact: { - id: 'contact-id', - name: 'name', - contactReferralsByMe: { - nodes, - pageInfo, - }, - }, - }, -}); - -const oneReferralMock = buildMocks([referralNode]); - -interface TestComponentProps { - mocks?: ApolloErgonoMockMap; - onCall?: jest.Mock; -} - -const TestComponent: React.FC = ({ - mocks = oneReferralMock, - onCall, -}) => ( - - - - - mocks={mocks as ApolloErgonoMockMap} - onCall={onCall} +describe('ContactReferralTab', () => { + it('test render', async () => { + const { findByText } = render( + + + mocks={{ + ContactReferralTab: { + contact: { + id: 'contact-id', + name: 'name', + contactReferralsByMe: { + nodes: [], + }, + }, + }, + }} > = ({ /> - - - -); - -describe('ContactReferralTab', () => { - it('test render', async () => { - const { findByText } = render(); + , + ); expect(await findByText('No Connections')).toBeVisible(); }); it('tests render with data and click event', async () => { - const { findByRole } = render(); + const { findByRole } = render( + + + mocks={{ + ContactReferralTab: { + contact: { + id: 'contact-id', + name: 'name', + contactReferralsByMe: { + nodes: [ + { + id: 'referral-id', + createdAt: '2021-04-29T07:48:28+0000', + referredTo: { + id: 'contact-id-2', + name: 'name-2', + }, + }, + ], + }, + }, + }, + }} + > + + + + + , + ); const contactLink = await findByRole('link', { name: 'name-2' }); @@ -115,108 +87,4 @@ describe('ContactReferralTab', () => { `/accountLists/${accountListId}/contacts/contact-id-2`, ); }); - - it('opens a confirmation when the remove button is clicked', async () => { - const { findByRole } = render(); - - userEvent.click( - await findByRole('button', { name: 'Remove Connection name-2' }), - ); - - expect(await findByRole('button', { name: 'Yes' })).toBeInTheDocument(); - expect( - await findByRole('heading', { name: 'Remove Connection' }), - ).toBeInTheDocument(); - }); - - it('does not fire the mutation when removal is cancelled', async () => { - const mutationSpy = jest.fn(); - const { findByRole, queryByRole } = render( - , - ); - - userEvent.click( - await findByRole('button', { name: 'Remove Connection name-2' }), - ); - userEvent.click(await findByRole('button', { name: 'No' })); - - // The dialog closes, the connection is still shown, and no mutation fired. - await waitFor(() => - expect(queryByRole('button', { name: 'Yes' })).not.toBeInTheDocument(), - ); - expect(await findByRole('link', { name: 'name-2' })).toBeInTheDocument(); - expect(mutationSpy).not.toHaveGraphqlOperation('DeleteContactReferral'); - }); - - it('removes the connection and shows a success message when confirmed', async () => { - const mutationSpy = jest.fn(); - const { findByRole, findByText, queryByRole } = render( - , - ); - - userEvent.click( - await findByRole('button', { name: 'Remove Connection name-2' }), - ); - userEvent.click(await findByRole('button', { name: 'Yes' })); - - await waitFor(() => - expect(mutationSpy).toHaveGraphqlOperation('DeleteContactReferral', { - accountListId, - contactId, - referralId: 'referral-id', - }), - ); - - expect( - await findByText('Connection removed successfully'), - ).toBeInTheDocument(); - await waitFor(() => - expect(queryByRole('link', { name: 'name-2' })).not.toBeInTheDocument(), - ); - }); - - it('removes the correct connection when multiple are present', async () => { - const mutationSpy = jest.fn(); - const { findByRole } = render( - , - ); - - userEvent.click( - await findByRole('button', { name: 'Remove Connection name-3' }), - ); - userEvent.click(await findByRole('button', { name: 'Yes' })); - - await waitFor(() => - expect(mutationSpy).toHaveGraphqlOperation('DeleteContactReferral', { - accountListId, - contactId, - referralId: 'referral-id-2', - }), - ); - }); - - it('shows an error message when removal fails', async () => { - const { findByRole, findByText } = render( - { - throw new Error('Server error'); - }, - }} - />, - ); - - userEvent.click( - await findByRole('button', { name: 'Remove Connection name-2' }), - ); - userEvent.click(await findByRole('button', { name: 'Yes' })); - - expect(await findByText('Unable to remove connection')).toBeInTheDocument(); - // The connection remains because the deletion did not succeed. - expect(await findByRole('link', { name: 'name-2' })).toBeInTheDocument(); - }); }); diff --git a/src/components/Contacts/ContactDetails/ContactReferralTab/ContactReferralTab.tsx b/src/components/Contacts/ContactDetails/ContactReferralTab/ContactReferralTab.tsx index ad8770a5ce..e945e3c809 100644 --- a/src/components/Contacts/ContactDetails/ContactReferralTab/ContactReferralTab.tsx +++ b/src/components/Contacts/ContactDetails/ContactReferralTab/ContactReferralTab.tsx @@ -1,11 +1,9 @@ import NextLink from 'next/link'; import React, { useState } from 'react'; import Add from '@mui/icons-material/Add'; -import DeleteIcon from '@mui/icons-material/Delete'; import { Box, Button, - IconButton, Link, Paper, Skeleton, @@ -19,22 +17,17 @@ import { } from '@mui/material'; import { styled } from '@mui/material/styles'; import { DateTime } from 'luxon'; -import { useSnackbar } from 'notistack'; import { useTranslation } from 'react-i18next'; import { DynamicCreateMultipleContacts, preloadCreateMultipleContacts, } from 'src/components/Layouts/Primary/TopBar/Items/AddMenu/Items/CreateMultipleContacts/DynamicCreateMultipleContacts'; import { useContactPanel } from 'src/components/Shared/ContactPanelProvider/ContactPanelProvider'; -import { Confirmation } from 'src/components/Shared/Modal/Confirmation/Confirmation'; import Modal from 'src/components/Shared/Modal/Modal'; import { useFetchAllPages } from 'src/hooks/useFetchAllPages'; import { useLocale } from 'src/hooks/useLocale'; import { dateFormat } from 'src/lib/intlFormat'; -import { - useContactReferralTabQuery, - useDeleteContactReferralMutation, -} from './ContactReferralTab.generated'; +import { useContactReferralTabQuery } from './ContactReferralTab.generated'; const ContactReferralContainer = styled(Box)(({ theme }) => ({ padding: theme.spacing(0), @@ -57,11 +50,6 @@ const AddButton = styled(Button)(({ theme }) => ({ color: theme.palette.info.main, })); -interface ReferralToRemove { - id: string; - name: string; -} - interface ContactReferralTabProps { accountListId: string; contactId: string; @@ -87,12 +75,8 @@ export const ContactReferralTab: React.FC = ({ const { t } = useTranslation(); const locale = useLocale(); - const { enqueueSnackbar } = useSnackbar(); - const [deleteContactReferral] = useDeleteContactReferralMutation(); const [modalContactReferralOpen, setModalContactReferralOpen] = useState(false); - const [referralToRemove, setReferralToRemove] = - useState(null); const handleModalOpen = () => { setModalContactReferralOpen(true); @@ -102,39 +86,6 @@ export const ContactReferralTab: React.FC = ({ setModalContactReferralOpen(false); }; - const handleRemoveReferral = async () => { - if (!referralToRemove) { - return; - } - await deleteContactReferral({ - variables: { - accountListId, - contactId, - referralId: referralToRemove.id, - }, - update: (cache) => { - const cacheId = cache.identify({ - __typename: 'Referral', - id: referralToRemove.id, - }); - if (cacheId) { - cache.evict({ id: cacheId }); - cache.gc(); - } - }, - onCompleted: () => { - enqueueSnackbar(t('Connection removed successfully'), { - variant: 'success', - }); - }, - onError: () => { - enqueueSnackbar(t('Unable to remove connection'), { - variant: 'error', - }); - }, - }); - }; - return ( {!data ? ( @@ -160,7 +111,6 @@ export const ContactReferralTab: React.FC = ({ {t('Name')} {t('Date of Connection')} - {t('Actions')} @@ -185,27 +135,12 @@ export const ContactReferralTab: React.FC = ({ {dateFormat(DateTime.fromISO(createdAt), locale)} - - - setReferralToRemove({ - id, - name: referredTo.name, - }) - } - > - - - ), ) ) : ( - {t('No Connections')} + {t('No Connections')} )} @@ -225,17 +160,6 @@ export const ContactReferralTab: React.FC = ({ referredById={contactId} /> - setReferralToRemove(null)} - mutation={handleRemoveReferral} - /> )} diff --git a/src/components/DonationTable/DonationTable.tsx b/src/components/DonationTable/DonationTable.tsx index bb7ea14eb4..75c530f92f 100644 --- a/src/components/DonationTable/DonationTable.tsx +++ b/src/components/DonationTable/DonationTable.tsx @@ -261,14 +261,14 @@ export const DonationTable: React.FC = ({ field: 'date', headerName: t('Date'), flex: 1, - minWidth: 90, + minWidth: 80, renderCell: date, }, { field: 'donorAccountName', headerName: hideDisplayName ? t('Partner No.') : t('Partner'), - flex: hideDisplayName ? 1 : 3, - minWidth: hideDisplayName ? 100 : 200, + flex: 3, + minWidth: 200, renderCell: donor, }, { diff --git a/src/components/HrTools/AdditionalSalaryRequest/AboutForm/AboutForm.test.tsx b/src/components/HrTools/AdditionalSalaryRequest/AboutForm/AboutForm.test.tsx index 1b63dcbd98..1889c7608f 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/AboutForm/AboutForm.test.tsx +++ b/src/components/HrTools/AdditionalSalaryRequest/AboutForm/AboutForm.test.tsx @@ -139,23 +139,15 @@ describe('AboutForm', () => { ).not.toBeInTheDocument(); }); - it('should link to the Progressive Approvals document', () => { - const { getByRole } = render(); + it('should have Progressive Approvals link', () => { + const { getByText } = render(); - expect( - getByRole('link', { name: 'Progressive Approvals' }), - ).toHaveAttribute( - 'href', - 'https://drive.google.com/file/d/1Z1WuiIUMrmfrUUV0V-ACCdhyuSd1Cgzg/view?usp=drive_link', - ); + expect(getByText('Progressive Approvals')).toBeInTheDocument(); }); - it('should link to the paper version document', () => { - const { getByRole } = render(); + it('should have paper version download link', () => { + const { getByText } = render(); - expect(getByRole('link', { name: 'paper version' })).toHaveAttribute( - 'href', - 'https://drive.google.com/file/d/1BXoJGnr9Gc3_KAek8jI8RsKAOo_We0JV/view?usp=drive_link', - ); + expect(getByText('paper version')).toBeInTheDocument(); }); }); diff --git a/src/components/HrTools/AdditionalSalaryRequest/AboutForm/AboutForm.tsx b/src/components/HrTools/AdditionalSalaryRequest/AboutForm/AboutForm.tsx index 853eefc03d..f350c13a71 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/AboutForm/AboutForm.tsx +++ b/src/components/HrTools/AdditionalSalaryRequest/AboutForm/AboutForm.tsx @@ -5,7 +5,6 @@ import { Trans, useTranslation } from 'react-i18next'; import { NameDisplay } from '../../Shared/CalculationReports/NameDisplay/NameDisplay'; import { useAdditionalSalaryRequest } from '../Shared/AdditionalSalaryRequestContext'; import { getHeader } from '../Shared/Helper/getHeader'; -import { paperVersionLink, progressiveApprovalsLink } from '../Shared/pdfLinks'; import { useFormUserInfo } from '../Shared/useFormUserInfo'; import { AdditionalSalaryRequestSection } from '../SharedComponents/AdditionalSalaryRequestSection'; import { SpouseComponent } from '../SharedComponents/SpouseComponent'; @@ -61,10 +60,12 @@ export const AboutForm: React.FC = () => { In special cases where requests exceed the remaining allowable salary, we require additional review through our{' '} { + e.preventDefault(); + // TODO: Implement Progressive Approvals navigation/modal + }} > Progressive Approvals {' '} @@ -74,10 +75,12 @@ export const AboutForm: React.FC = () => { Alternatively, you may download and submit the{' '} { + e.preventDefault(); + // TODO: Implement paper version download + }} > paper version {' '} diff --git a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/CapSubContent.test.tsx b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/CapSubContent.test.tsx index f51855318a..933e89f16b 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/CapSubContent.test.tsx +++ b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/CapSubContent.test.tsx @@ -71,17 +71,6 @@ describe('CapSubContent', () => { ).not.toBeInTheDocument(); }); - it('should link to the Progressive Approvals document', () => { - const { getByRole } = renderCapSubContent(); - - expect( - getByRole('link', { name: 'Progressive Approvals' }), - ).toHaveAttribute( - 'href', - 'https://drive.google.com/file/d/1Z1WuiIUMrmfrUUV0V-ACCdhyuSd1Cgzg/view?usp=drive_link', - ); - }); - it('renders nothing when reason is board cap exception (message is carried by getCapOverrides)', () => { mockUseAdditionalSalaryRequest.mockReturnValue({ requestData: { diff --git a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/CapSubContent.tsx b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/CapSubContent.tsx index 0e94549cae..25a1f22d25 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/CapSubContent.tsx +++ b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/CapSubContent.tsx @@ -11,7 +11,6 @@ import theme from 'src/theme'; import { CompleteFormValues } from '../../AdditionalSalaryRequest'; import { useAdditionalSalaryRequest } from '../../Shared/AdditionalSalaryRequestContext'; import { getTotal } from '../../Shared/Helper/getTotal'; -import { progressiveApprovalsLink } from '../../Shared/pdfLinks'; export const CapSubContent: React.FC = () => { const { t } = useTranslation(); @@ -36,9 +35,7 @@ export const CapSubContent: React.FC = () => { Please complete the Approval Process section below and we will review your request through our{' '} Progressive Approvals diff --git a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SplitCapSubContent.test.tsx b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SplitCapSubContent.test.tsx index 28de9e9c86..d6024b013b 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SplitCapSubContent.test.tsx +++ b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SplitCapSubContent.test.tsx @@ -75,21 +75,15 @@ describe('SplitCapSubContent', () => { const { getByText, getByRole } = renderSplitCapSubContent('Jane'); - const progressiveApprovalsLink = getByRole('link', { - name: 'Progressive Approvals', - }); - expect( getByText(/Please make adjustments to your request/), ).toBeInTheDocument(); expect( getByText(/separate request up to Jane's maximum allowable salary/), ).toBeInTheDocument(); - expect(progressiveApprovalsLink).toBeInTheDocument(); - expect(progressiveApprovalsLink).toHaveAttribute( - 'href', - 'https://drive.google.com/file/d/1Z1WuiIUMrmfrUUV0V-ACCdhyuSd1Cgzg/view?usp=drive_link', - ); + expect( + getByRole('link', { name: 'Progressive Approvals' }), + ).toBeInTheDocument(); }); it('renders status bar header with spouse salary and cap amounts', () => { diff --git a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SplitCapSubContent.tsx b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SplitCapSubContent.tsx index db1ba2afc5..c367e1ea89 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SplitCapSubContent.tsx +++ b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SplitCapSubContent.tsx @@ -6,7 +6,6 @@ import { useLocale } from 'src/hooks/useLocale'; import { currencyFormat } from 'src/lib/intlFormat'; import theme from 'src/theme'; import { CompleteFormValues } from '../../AdditionalSalaryRequest'; -import { progressiveApprovalsLink } from '../../Shared/pdfLinks'; import { useSalaryCalculations } from '../../Shared/useSalaryCalculations'; interface SplitCapSubContentProps { @@ -59,9 +58,7 @@ export const SplitCapSubContent: React.FC = ({ salary, any additional requests can be submitted online but will require approval through our{' '} Progressive Approvals diff --git a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SpouseOverCapSubContent.test.tsx b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SpouseOverCapSubContent.test.tsx index 97c7c31d5f..b057feeb96 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SpouseOverCapSubContent.test.tsx +++ b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SpouseOverCapSubContent.test.tsx @@ -13,9 +13,6 @@ describe('SpouseOverCapSubContent', () => { expect(getByText(/Jane/)).toBeInTheDocument(); const link = getByRole('link', { name: 'Progressive Approvals' }); - expect(link).toHaveAttribute( - 'href', - 'https://drive.google.com/file/d/1Z1WuiIUMrmfrUUV0V-ACCdhyuSd1Cgzg/view?usp=drive_link', - ); + expect(link).toBeInTheDocument(); }); }); diff --git a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SpouseOverCapSubContent.tsx b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SpouseOverCapSubContent.tsx index 211c77d6a4..173ef97251 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SpouseOverCapSubContent.tsx +++ b/src/components/HrTools/AdditionalSalaryRequest/RequestPage/Helper/SpouseOverCapSubContent.tsx @@ -1,7 +1,6 @@ import Link from 'next/link'; import { Trans, useTranslation } from 'react-i18next'; import theme from 'src/theme'; -import { progressiveApprovalsLink } from '../../Shared/pdfLinks'; interface SpouseOverCapSubContentProps { spouseName: string; @@ -18,9 +17,7 @@ export const SpouseOverCapSubContent: React.FC< to reduce the amount on {spouseName}'s request, which may avoid requiring approval through our{' '} Progressive Approvals diff --git a/src/components/HrTools/AdditionalSalaryRequest/Shared/pdfLinks.ts b/src/components/HrTools/AdditionalSalaryRequest/Shared/pdfLinks.ts deleted file mode 100644 index f13f002e36..0000000000 --- a/src/components/HrTools/AdditionalSalaryRequest/Shared/pdfLinks.ts +++ /dev/null @@ -1,4 +0,0 @@ -export const progressiveApprovalsLink = - 'https://drive.google.com/file/d/1Z1WuiIUMrmfrUUV0V-ACCdhyuSd1Cgzg/view?usp=drive_link'; -export const paperVersionLink = - 'https://drive.google.com/file/d/1BXoJGnr9Gc3_KAek8jI8RsKAOo_We0JV/view?usp=drive_link'; diff --git a/src/components/HrTools/AdditionalSalaryRequest/SharedComponents/ReceiptAlertText.tsx b/src/components/HrTools/AdditionalSalaryRequest/SharedComponents/ReceiptAlertText.tsx index bbff896296..ebdbbcb582 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/SharedComponents/ReceiptAlertText.tsx +++ b/src/components/HrTools/AdditionalSalaryRequest/SharedComponents/ReceiptAlertText.tsx @@ -4,8 +4,8 @@ import { Box, List, ListItemText } from '@mui/material'; import { Trans, useTranslation } from 'react-i18next'; import { StyledListItem } from 'src/components/HrTools/SavingsFundTransfer/styledComponents/StyledListItem'; import theme from 'src/theme'; -import { progressiveApprovalsLink } from '../Shared/pdfLinks'; +//TODO [MPDX-9303]: Add link for Progressive Approvals //TODO [MPDX-9303]: Get number of days for approval time frame interface BulletListProps { @@ -38,9 +38,7 @@ export const ExceedsCapAlertText: React.FC = () => { Because your request exceeds your remaining allowable salary it requires additional review. We will review your request through{' '} Progressive Approvals diff --git a/src/components/HrTools/AdditionalSalaryRequest/SubmitModalAccordions/ApprovalProcess/ApprovalProcess.test.tsx b/src/components/HrTools/AdditionalSalaryRequest/SubmitModalAccordions/ApprovalProcess/ApprovalProcess.test.tsx index 69067fb5be..5c36717ccf 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/SubmitModalAccordions/ApprovalProcess/ApprovalProcess.test.tsx +++ b/src/components/HrTools/AdditionalSalaryRequest/SubmitModalAccordions/ApprovalProcess/ApprovalProcess.test.tsx @@ -94,12 +94,4 @@ describe('ApprovalProcess', () => { expect(getByRole('textbox')).toBeInTheDocument(); }); - - it('gives the comment textarea a stable accessible name and id', () => { - const { getByRole } = render(); - - const textbox = getByRole('textbox', { name: 'Additional Information' }); - expect(textbox).toBeInTheDocument(); - expect(textbox).toHaveAttribute('id', 'asr-additional-info'); - }); }); diff --git a/src/components/HrTools/AdditionalSalaryRequest/SubmitModalAccordions/ApprovalProcess/ApprovalProcess.tsx b/src/components/HrTools/AdditionalSalaryRequest/SubmitModalAccordions/ApprovalProcess/ApprovalProcess.tsx index 3b901555e6..19a97ce00a 100644 --- a/src/components/HrTools/AdditionalSalaryRequest/SubmitModalAccordions/ApprovalProcess/ApprovalProcess.tsx +++ b/src/components/HrTools/AdditionalSalaryRequest/SubmitModalAccordions/ApprovalProcess/ApprovalProcess.tsx @@ -58,14 +58,10 @@ export const ApprovalProcess: React.FC = ({ diff --git a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/AutosaveTextField.test.tsx b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/AutosaveTextField.test.tsx index f9e5ded974..feb0e7914a 100644 --- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/AutosaveTextField.test.tsx +++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/Autosave/AutosaveTextField.test.tsx @@ -124,10 +124,10 @@ describe('AutosaveTextField', () => { userEvent.clear(input); userEvent.type(input, '-100'); - userEvent.tab(); - expect(input).toHaveAccessibleDescription('MHA Amount must be positive'); + input.blur(); + await Promise.resolve(); await waitFor(() => expect(mutationSpy).not.toHaveGraphqlOperation('UpdateGoalCalculation'), @@ -153,8 +153,6 @@ describe('AutosaveTextField', () => { userEvent.clear(input); userEvent.type(input, 'abc'); - userEvent.tab(); - expect(input).toHaveAccessibleDescription('MHA Amount must be a number'); }); diff --git a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategory.test.tsx b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategory.test.tsx index 087a09ecc0..cf676cb4ca 100644 --- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategory.test.tsx +++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategory.test.tsx @@ -290,10 +290,8 @@ describe('InformationCategory', () => { userEvent.type(input, '-1000'); input.blur(); - await waitFor(() => - expect(input).toHaveAccessibleDescription( - 'MHA Amount Per Paycheck must be positive', - ), + expect(input).toHaveAccessibleDescription( + 'MHA Amount Per Paycheck must be positive', ); await waitFor(() => diff --git a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryFinancialForm.tsx b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryFinancialForm.tsx index 1365485f4b..a3d4c5c82d 100644 --- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryFinancialForm.tsx +++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryFinancialForm.tsx @@ -13,7 +13,7 @@ import { useGoalCalculator } from 'src/components/HrTools/GoalCalculator/Shared/ import { CurrencyAdornment, PercentageAdornment, -} from 'src/components/HrTools/Shared/Adornments'; +} from '../../../../Shared/Adornments'; import { AutosaveTextField } from '../../Autosave/AutosaveTextField'; import { useSaveField } from '../../Autosave/useSaveField'; import { Contribution403bHelperPanel } from '../InformationHelperPanel/Contribution403bHelperPanel'; diff --git a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx index 5c184a9614..e7caffd818 100644 --- a/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx +++ b/src/components/HrTools/GoalCalculator/CalculatorSettings/Categories/InformationCategory/InformationCategoryForm/InformationCategoryPersonalForm.tsx @@ -19,8 +19,6 @@ import { MpdGoalBenefitsConstantSizeEnum, } from 'src/graphql/types.generated'; import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; -import { getLocalizedAge } from 'src/lib/functions/getLocalizedAge'; -import { getLocalizedRole } from 'src/lib/functions/getLocalizedRole'; import { AutosaveTextField } from '../../Autosave/AutosaveTextField'; import { useSaveField } from '../../Autosave/useSaveField'; import { BenefitsPlanHelperPanel } from '../InformationHelperPanel/BenefitsPlanHelperPanel'; @@ -147,13 +145,12 @@ export const InformationCategoryPersonalForm: React.FC< select label={t('Role Type')} > - {[GoalCalculationRole.Office, GoalCalculationRole.Field].map( - (role) => ( - - {getLocalizedRole(t, role)} - - ), - )} + + {t('Office')} + + + {t('Field')} + )} @@ -247,16 +244,18 @@ export const InformationCategoryPersonalForm: React.FC< label={isSpouse ? t('Spouse Age') : t('Age')} helperText={t('For new staff reference goal')} > - {[ - GoalCalculationAge.UnderThirty, - GoalCalculationAge.ThirtyToThirtyFour, - GoalCalculationAge.ThirtyFiveToThirtyNine, - GoalCalculationAge.OverForty, - ].map((age) => ( - - {getLocalizedAge(t, age)} - - ))} + + {t('Under 30')} + + + {t('30-34')} + + + {t('35-39')} + + + {t('Over 40')} + diff --git a/src/components/HrTools/GoalCalculator/GoalsList/GoalsList.test.tsx b/src/components/HrTools/GoalCalculator/GoalsList/GoalsList.test.tsx index 317f5b7669..199aee52ae 100644 --- a/src/components/HrTools/GoalCalculator/GoalsList/GoalsList.test.tsx +++ b/src/components/HrTools/GoalCalculator/GoalsList/GoalsList.test.tsx @@ -1,6 +1,5 @@ import React from 'react'; import { render, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import TestRouter from '__tests__/util/TestRouter'; import { GqlMockedProvider } from '__tests__/util/graphqlMocking'; import { GoalCalculationsQuery } from './GoalCalculations.generated'; @@ -87,23 +86,6 @@ describe('GoalsList', () => { expect(await findByRole('img')).toBeInTheDocument(); }); - it('disables the create button while creating a goal', async () => { - const { getByRole } = render(); - - const button = getByRole('button', { name: 'Create a New Goal' }); - expect(button).not.toBeDisabled(); - - userEvent.click(button); - - expect(button).toBeDisabled(); - - await waitFor(() => - expect(mutationSpy).toHaveGraphqlOperation('CreateGoalCalculation', { - accountListId: 'account-list-1', - }), - ); - }); - it('fetches additional pages when hasNextPage is true', async () => { render(); diff --git a/src/components/HrTools/GoalCalculator/GoalsList/GoalsList.tsx b/src/components/HrTools/GoalCalculator/GoalsList/GoalsList.tsx index 4b4598f08d..df5f880593 100644 --- a/src/components/HrTools/GoalCalculator/GoalsList/GoalsList.tsx +++ b/src/components/HrTools/GoalCalculator/GoalsList/GoalsList.tsx @@ -35,10 +35,9 @@ export const GoalsList: React.FC = () => { error, pageInfo: data?.goalCalculations.pageInfo, }); - const [createGoalCalculation, { loading: creating }] = - useCreateGoalCalculationMutation({ - variables: { accountListId }, - }); + const [createGoalCalculation] = useCreateGoalCalculationMutation({ + variables: { accountListId }, + }); const goals = data?.goalCalculations.nodes ?.slice() .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); @@ -61,12 +60,7 @@ export const GoalsList: React.FC = () => { - - ); -}; - -describe('MpdSupervisorReportContext — selectedTabKey to URL', () => { - it('syncs the selected tab back to the URL on change', async () => { - const replace = jest.fn(); - const { getByTestId } = render( - - - - - , - ); - - await act(async () => { - getByTestId('tab').click(); - }); - - expect(replace).toHaveBeenCalledWith( - expect.objectContaining({ - query: expect.objectContaining({ tab: StaffDetailTabEnum.Payroll }), - }), - undefined, - { shallow: true }, - ); - }); -}); diff --git a/src/components/HrTools/MpdSupervisorReport/MpdSupervisorReportContext.tsx b/src/components/HrTools/MpdSupervisorReport/MpdSupervisorReportContext.tsx deleted file mode 100644 index 8788739e1c..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/MpdSupervisorReportContext.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { useRouter } from 'next/router'; -import React, { - createContext, - useCallback, - useContext, - useMemo, - useState, -} from 'react'; -import { - ALL_TEAMS, - ALL_TYPES, - MpdSupervisorReportEmploymentTypeEnum, - MpdSupervisorReportQuickFilterEnum, - MpdSupervisorReportTeamsEnum, -} from './Filters/mpdSupervisorReportFilters'; -import { StaffDetailTabEnum } from './StaffDetailsTabs/StaffDetailTab'; -import { EmployeeData } from './mockData'; - -export enum Panel { - Navigation = 'Navigation', - Filters = 'Filters', -} - -export interface MpdSupervisorReportContextValue { - selectedMember: EmployeeData | undefined; - isOpen: boolean; - openMember: (member: EmployeeData) => void; - closePanel: () => void; - search: string; - setSearch: (v: string) => void; - team: MpdSupervisorReportTeamsEnum; - setTeam: (v: MpdSupervisorReportTeamsEnum) => void; - employmentType: MpdSupervisorReportEmploymentTypeEnum; - setEmploymentType: (v: MpdSupervisorReportEmploymentTypeEnum) => void; - activeQuickFilter: MpdSupervisorReportQuickFilterEnum; - setActiveQuickFilter: (v: MpdSupervisorReportQuickFilterEnum) => void; - selectedTabKey: StaffDetailTabEnum; - setSelectedTabKey: React.Dispatch>; - handleTabChange: ( - event: React.SyntheticEvent, - newKey: StaffDetailTabEnum, - ) => void; -} - -export const MpdSupervisorReportContext = createContext< - MpdSupervisorReportContextValue | undefined ->(undefined); - -// Resolve the selected tab from the `?tab=` query param, guarding against -// arbitrary URL input (arrays or values that aren't a real tab). -const parseTabFromQuery = ( - tab: string | string[] | undefined, -): StaffDetailTabEnum => { - const value = Array.isArray(tab) ? tab[0] : tab; - return Object.values(StaffDetailTabEnum).includes(value as StaffDetailTabEnum) - ? (value as StaffDetailTabEnum) - : StaffDetailTabEnum.MonthlySummary; -}; - -export const MpdSupervisorReportProvider: React.FC<{ - children: React.ReactNode; -}> = ({ children }) => { - const router = useRouter(); - const query = router?.query; - - const [selectedMember, setSelectedMember] = useState< - EmployeeData | undefined - >(undefined); - const [search, setSearch] = useState(''); - const [team, setTeam] = useState(ALL_TEAMS); - const [employmentType, setEmploymentType] = - useState(ALL_TYPES); - const [activeQuickFilter, setActiveQuickFilter] = - useState( - MpdSupervisorReportQuickFilterEnum.AllPeople, - ); - const [selectedTabKey, setSelectedTabKey] = useState(() => - parseTabFromQuery(query?.tab), - ); - const handleTabChange = useCallback( - (_event: React.SyntheticEvent, newKey: StaffDetailTabEnum) => { - setSelectedTabKey(newKey); - router?.replace({ query: { ...router.query, tab: newKey } }, undefined, { - shallow: true, - }); - }, - [router], - ); - - const value = useMemo( - () => ({ - selectedMember, - isOpen: selectedMember !== undefined, - openMember: (member: EmployeeData) => setSelectedMember(member), - closePanel: () => setSelectedMember(undefined), - search, - setSearch, - team, - setTeam, - employmentType, - setEmploymentType, - activeQuickFilter, - setActiveQuickFilter, - selectedTabKey, - setSelectedTabKey, - handleTabChange, - }), - [ - selectedMember, - search, - team, - employmentType, - activeQuickFilter, - selectedTabKey, - handleTabChange, - ], - ); - - return ( - - {children} - - ); -}; - -export const useMpdSupervisorReport = (): MpdSupervisorReportContextValue => { - const ctx = useContext(MpdSupervisorReportContext); - if (!ctx) { - throw new Error( - 'useMpdSupervisorReport must be used within a MpdSupervisorReportProvider', - ); - } - return ctx; -}; diff --git a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/MPGA/DynamicMPGA.tsx b/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/MPGA/DynamicMPGA.tsx deleted file mode 100644 index 2d8add839a..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/MPGA/DynamicMPGA.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import dynamic from 'next/dynamic'; -import { DynamicComponentPlaceholder } from 'src/components/DynamicPlaceholders/DynamicComponentPlaceholder'; - -export const preloadMPGA = () => - import(/* webpackChunkName: "MPGA" */ './MPGA').then( - ({ StaffTabMPGA }) => StaffTabMPGA, - ); - -export const DynamicMPGA = dynamic(preloadMPGA, { - loading: DynamicComponentPlaceholder, -}); diff --git a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/MPGA/MPGA.tsx b/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/MPGA/MPGA.tsx deleted file mode 100644 index 55561edce6..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/MPGA/MPGA.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import { Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; - -export const StaffTabMPGA: React.FC = () => { - const { t } = useTranslation(); - - return {t('MPGA')}; -}; diff --git a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/MonthlySummary/DynamicMonthlySummary.tsx b/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/MonthlySummary/DynamicMonthlySummary.tsx deleted file mode 100644 index 48851e7ee4..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/MonthlySummary/DynamicMonthlySummary.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import dynamic from 'next/dynamic'; -import { DynamicComponentPlaceholder } from 'src/components/DynamicPlaceholders/DynamicComponentPlaceholder'; - -export const preloadMonthlySummary = () => - import(/* webpackChunkName: "MonthlySummary" */ './MonthlySummary').then( - ({ StaffTabMonthlySummary }) => StaffTabMonthlySummary, - ); - -export const DynamicMonthlySummary = dynamic(preloadMonthlySummary, { - loading: DynamicComponentPlaceholder, -}); diff --git a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/MonthlySummary/MonthlySummary.tsx b/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/MonthlySummary/MonthlySummary.tsx deleted file mode 100644 index fd72dc116d..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/MonthlySummary/MonthlySummary.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import { Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; - -export const StaffTabMonthlySummary: React.FC = () => { - const { t } = useTranslation(); - - return {t('Monthly Summary')}; -}; diff --git a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/Payroll/DynamicPayroll.tsx b/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/Payroll/DynamicPayroll.tsx deleted file mode 100644 index 82458ce579..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/Payroll/DynamicPayroll.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import dynamic from 'next/dynamic'; -import { DynamicComponentPlaceholder } from 'src/components/DynamicPlaceholders/DynamicComponentPlaceholder'; - -export const preloadPayroll = () => - import(/* webpackChunkName: "Payroll" */ './Payroll').then( - ({ StaffTabPayroll }) => StaffTabPayroll, - ); - -export const DynamicPayroll = dynamic(preloadPayroll, { - loading: DynamicComponentPlaceholder, -}); diff --git a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/Payroll/Payroll.tsx b/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/Payroll/Payroll.tsx deleted file mode 100644 index 29fbde826b..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/Payroll/Payroll.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import { Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; - -export const StaffTabPayroll: React.FC = () => { - const { t } = useTranslation(); - - return {t('Payroll')}; -}; diff --git a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/Quarterly/DynamicQuarterly.tsx b/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/Quarterly/DynamicQuarterly.tsx deleted file mode 100644 index 41f9809fd0..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/Quarterly/DynamicQuarterly.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import dynamic from 'next/dynamic'; -import { DynamicComponentPlaceholder } from 'src/components/DynamicPlaceholders/DynamicComponentPlaceholder'; - -export const preloadQuarterly = () => - import(/* webpackChunkName: "Quarterly" */ './Quarterly').then( - ({ StaffTabQuarterly }) => StaffTabQuarterly, - ); - -export const DynamicQuarterly = dynamic(preloadQuarterly, { - loading: DynamicComponentPlaceholder, -}); diff --git a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/Quarterly/Quarterly.test.tsx b/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/Quarterly/Quarterly.test.tsx deleted file mode 100644 index 334e01e1ce..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/Quarterly/Quarterly.test.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { act, render, screen } from '@testing-library/react'; -import TestRouter from '__tests__/util/TestRouter'; -import theme from 'src/theme'; -import { - MpdSupervisorReportProvider, - useMpdSupervisorReport, -} from '../../MpdSupervisorReportContext'; -import { EmployeeData, QuarterHealthEnum } from '../../mockData'; -import { StaffTabQuarterly } from './Quarterly'; - -const member: EmployeeData = { - user: { - id: '1', - preferredName: 'John', - lastName: 'Smith', - personNumber: '10000001', - staffAccountID: '1000000001', - userPersonType: 'Full time', - team: 'Campus', - }, - quarters: [ - { label: 'FQ4 25', health: QuarterHealthEnum.Green, payroll: 15000 }, - { label: 'FQ1 26', health: QuarterHealthEnum.Yellow, payroll: 16000 }, - { label: 'FQ2 26', health: QuarterHealthEnum.Red, payroll: 17000 }, - { label: 'FQ3 26', health: QuarterHealthEnum.Green, payroll: 18000 }, - ], -}; - -let openMemberFn: (member: EmployeeData) => void; - -const Opener: React.FC = () => { - openMemberFn = useMpdSupervisorReport().openMember; - return null; -}; - -const renderQuarterly = () => - render( - - - - - - - - , - ); - -describe('StaffTabQuarterly', () => { - it('renders nothing when no member is selected', () => { - const { container } = renderQuarterly(); - expect(container.firstChild).toBeNull(); - }); - - it('renders the heading and a chip per quarter (all health colors) once a member is selected', () => { - renderQuarterly(); - act(() => openMemberFn(member)); - - expect(screen.getByText('Fiscal Year Quarters')).toBeInTheDocument(); - // One chip per quarter, covering Green, Yellow, and Red health mappings. - expect(screen.getByText('FQ4 25')).toBeInTheDocument(); // Green - expect(screen.getByText('FQ1 26')).toBeInTheDocument(); // Yellow - expect(screen.getByText('FQ2 26')).toBeInTheDocument(); // Red - expect(screen.getByText('FQ3 26')).toBeInTheDocument(); // Green - }); -}); diff --git a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/Quarterly/Quarterly.tsx b/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/Quarterly/Quarterly.tsx deleted file mode 100644 index 718e95b72b..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/Quarterly/Quarterly.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; -import { Chip, Typography } from '@mui/material'; -import { Box } from '@mui/system'; -import { useTranslation } from 'react-i18next'; -import { useMpdSupervisorReport } from '../../MpdSupervisorReportContext'; -import { healthColor } from '../../helpers'; -import { QuarterStatus } from '../../mockData'; - -export const StaffTabQuarterly: React.FC = () => { - const { t } = useTranslation(); - const { selectedMember } = useMpdSupervisorReport(); - - if (!selectedMember) { - return null; - } - - const { quarters } = selectedMember; - - return ( - <> - {t('Fiscal Year Quarters')} - - - - - ); -}; - -interface QuarterChipsProps { - quarters: QuarterStatus[]; -} - -const QuarterChips: React.FC = ({ quarters }) => ( - - {quarters.map((quarter) => ( - { - const { bg, color } = healthColor(theme, quarter.health); - return { - height: 22, - fontWeight: 600, - backgroundColor: bg, - color, - '& .MuiChip-label': { - paddingInline: theme.spacing(1), - }, - }; - }} - /> - ))} - -); diff --git a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/StaffDetailTab.ts b/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/StaffDetailTab.ts deleted file mode 100644 index 72af1f02c2..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/StaffDetailTab.ts +++ /dev/null @@ -1,7 +0,0 @@ -export enum StaffDetailTabEnum { - MonthlySummary = 'MonthlySummary', - Quarterly = 'Quarterly', - Payroll = 'Payroll', - MPGAReport = 'MPGAReport', - StaffExpenseReport = 'StaffExpenseReport', -} diff --git a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/StaffExpenseReport/DynamicStaffExpenseReport.tsx b/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/StaffExpenseReport/DynamicStaffExpenseReport.tsx deleted file mode 100644 index e0966fcf6f..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/StaffExpenseReport/DynamicStaffExpenseReport.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import dynamic from 'next/dynamic'; -import { DynamicComponentPlaceholder } from 'src/components/DynamicPlaceholders/DynamicComponentPlaceholder'; - -export const preloadStaffExpenseReport = () => - import( - /* webpackChunkName: "StaffExpenseReport" */ './StaffExpenseReport' - ).then(({ StaffTabStaffExpenseReport }) => StaffTabStaffExpenseReport); - -export const DynamicStaffExpenseReport = dynamic(preloadStaffExpenseReport, { - loading: DynamicComponentPlaceholder, -}); diff --git a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/StaffExpenseReport/StaffExpenseReport.tsx b/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/StaffExpenseReport/StaffExpenseReport.tsx deleted file mode 100644 index 7629f7e861..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/StaffDetailsTabs/StaffExpenseReport/StaffExpenseReport.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import { Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; - -export const StaffTabStaffExpenseReport: React.FC = () => { - const { t } = useTranslation(); - - return {t('Staff Expense Report')}; -}; diff --git a/src/components/HrTools/MpdSupervisorReport/StaffMemberDrawer/StaffMemberDrawer.test.tsx b/src/components/HrTools/MpdSupervisorReport/StaffMemberDrawer/StaffMemberDrawer.test.tsx deleted file mode 100644 index a4d30b0e0b..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/StaffMemberDrawer/StaffMemberDrawer.test.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { act, render, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import TestRouter from '__tests__/util/TestRouter'; -import theme from 'src/theme'; -import { - MpdSupervisorReportProvider, - useMpdSupervisorReport, -} from '../MpdSupervisorReportContext'; -import { EmployeeData, QuarterHealthEnum } from '../mockData'; -import { StaffMemberDrawer } from './StaffMemberDrawer'; - -const memberWithSpouse: EmployeeData = { - user: { - id: '1', - preferredName: 'John', - lastName: 'Smith', - personNumber: '10000001', - staffAccountID: '1000000001', - userPersonType: 'Full time', - team: 'Campus', - }, - spouse: { - id: '2', - preferredName: 'Jane', - lastName: 'Smith', - personNumber: '10000002', - staffAccountID: '1000000002', - }, - quarters: [ - { label: 'FQ4 25', health: QuarterHealthEnum.Green, payroll: 15000 }, - { label: 'FQ1 26', health: QuarterHealthEnum.Yellow, payroll: 15000 }, - { label: 'FQ2 26', health: QuarterHealthEnum.Red, payroll: 15000 }, - { label: 'FQ3 26', health: QuarterHealthEnum.Green, payroll: 15000 }, - ], -}; - -const memberWithoutSpouse: EmployeeData = { - user: { - id: '3', - preferredName: 'Alice', - lastName: 'Jones', - personNumber: '10000003', - staffAccountID: '1000000003', - userPersonType: 'Part time', - team: 'Digital strategies', - }, - quarters: [ - { label: 'FQ4 25', health: QuarterHealthEnum.Red, payroll: 15000 }, - { label: 'FQ1 26', health: QuarterHealthEnum.Red, payroll: 15000 }, - { label: 'FQ2 26', health: QuarterHealthEnum.Red, payroll: 15000 }, - { label: 'FQ3 26', health: QuarterHealthEnum.Red, payroll: 15000 }, - ], -}; - -let openMemberFn: (member: EmployeeData) => void; - -const Opener: React.FC = () => { - const { openMember } = useMpdSupervisorReport(); - openMemberFn = openMember; - return null; -}; - -const renderDrawer = () => - render( - - - - - - - - , - ); - -const openMember = (member: EmployeeData) => { - act(() => { - openMemberFn(member); - }); -}; - -describe('StaffMemberDrawer', () => { - it('renders nothing when no member is selected', () => { - const { container } = renderDrawer(); - expect(container.firstChild).toBeNull(); - }); - - it('renders the member name and person number after openMember is called', () => { - renderDrawer(); - openMember(memberWithSpouse); - expect(screen.getByText('John Smith')).toBeInTheDocument(); - expect(screen.getByText('10000001')).toBeInTheDocument(); - expect(screen.getByText('1000000001')).toBeInTheDocument(); - }); - - it('renders the spouse section when a spouse is present', () => { - renderDrawer(); - openMember(memberWithSpouse); - expect(screen.getByText(/Spouse:/)).toHaveTextContent('Spouse: Jane Smith'); - expect(screen.getByText('10000002')).toBeInTheDocument(); - }); - - it('does not render the spouse section when no spouse is present', () => { - renderDrawer(); - openMember(memberWithoutSpouse); - expect(screen.getByText('Alice Jones')).toBeInTheDocument(); - expect(screen.queryByText(/Spouse:/)).not.toBeInTheDocument(); - }); - - it('renders all five detail tabs', () => { - renderDrawer(); - openMember(memberWithSpouse); - expect( - screen.getByRole('tab', { name: 'Monthly Summary' }), - ).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Quarterly' })).toBeInTheDocument(); - expect(screen.getByRole('tab', { name: 'Payroll' })).toBeInTheDocument(); - expect( - screen.getByRole('tab', { name: 'MPGA Report' }), - ).toBeInTheDocument(); - expect( - screen.getByRole('tab', { name: 'Staff Expense Report' }), - ).toBeInTheDocument(); - }); - - it('shows the Monthly Summary tab and its panel content by default', () => { - renderDrawer(); - openMember(memberWithSpouse); - expect( - screen.getByRole('tab', { name: 'Monthly Summary' }), - ).toHaveAttribute('aria-selected', 'true'); - expect( - within(screen.getByRole('tabpanel')).getByText('Monthly Summary'), - ).toBeInTheDocument(); - }); - - it('selects another tab when clicked', async () => { - renderDrawer(); - openMember(memberWithSpouse); - await userEvent.click(screen.getByRole('tab', { name: 'Quarterly' })); - expect(screen.getByRole('tab', { name: 'Quarterly' })).toHaveAttribute( - 'aria-selected', - 'true', - ); - expect( - screen.getByRole('tab', { name: 'Monthly Summary' }), - ).toHaveAttribute('aria-selected', 'false'); - }); - - it('closes the panel when the close button is clicked', async () => { - renderDrawer(); - openMember(memberWithSpouse); - expect(screen.getByText('John Smith')).toBeInTheDocument(); - await userEvent.click(screen.getByRole('button', { name: 'Close' })); - expect(screen.queryByText('John Smith')).not.toBeInTheDocument(); - }); -}); diff --git a/src/components/HrTools/MpdSupervisorReport/StaffMemberDrawer/StaffMemberDrawer.tsx b/src/components/HrTools/MpdSupervisorReport/StaffMemberDrawer/StaffMemberDrawer.tsx deleted file mode 100644 index 9a6fd9deea..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/StaffMemberDrawer/StaffMemberDrawer.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import React from 'react'; -import CloseIcon from '@mui/icons-material/Close'; -import { TabContext, TabList, TabPanel } from '@mui/lab'; -import { Avatar, Box, IconButton, Tab, Typography } from '@mui/material'; -import { styled } from '@mui/material/styles'; -import { useTranslation } from 'react-i18next'; -import theme from 'src/theme'; -import { useMpdSupervisorReport } from '../MpdSupervisorReportContext'; -import { DynamicMPGA, preloadMPGA } from '../StaffDetailsTabs/MPGA/DynamicMPGA'; -import { preloadMonthlySummary } from '../StaffDetailsTabs/MonthlySummary/DynamicMonthlySummary'; -import { StaffTabMonthlySummary } from '../StaffDetailsTabs/MonthlySummary/MonthlySummary'; -import { - DynamicPayroll, - preloadPayroll, -} from '../StaffDetailsTabs/Payroll/DynamicPayroll'; -import { - DynamicQuarterly, - preloadQuarterly, -} from '../StaffDetailsTabs/Quarterly/DynamicQuarterly'; -import { StaffDetailTabEnum } from '../StaffDetailsTabs/StaffDetailTab'; -import { preloadStaffExpenseReport } from '../StaffDetailsTabs/StaffExpenseReport/DynamicStaffExpenseReport'; -import { StaffTabStaffExpenseReport } from '../StaffDetailsTabs/StaffExpenseReport/StaffExpenseReport'; -import { getInitials } from '../helpers'; - -interface DetailRowProps { - label: string; - value: string; -} - -const DetailRow: React.FC = ({ label, value }) => ( - - - {label} - - {value} - -); - -const StaffInfo = styled(Box)(({ theme }) => ({ - display: 'flex', - flexDirection: 'row', - gap: theme.spacing(2), - flexWrap: 'wrap', -})); - -const ContactTabsWrapper = styled(Box)(({}) => ({ - width: '100%', - backgroundColor: 'transparent', - boxShadow: 'none', - borderBottom: `1px solid ${theme.palette.divider}`, -})); - -const ContactTabs = styled(TabList)(({ theme }) => ({ - width: '100%', - minHeight: 40, - '& .MuiTabs-indicator': { - backgroundColor: theme.palette.progressBarYellow.main, - }, -})); - -const ContactTab = styled(Tab)(({}) => ({ - textTransform: 'none', - minWidth: 64, - minHeight: 40, - marginRight: theme.spacing(1), - color: theme.palette.text.primary, - opacity: 0.75, - '&:hover': { opacity: 1 }, -})); - -export const StaffMemberDrawer: React.FC = () => { - const { t } = useTranslation(); - const { - selectedMember, - closePanel, - selectedTabKey, - handleTabChange: handleChange, - } = useMpdSupervisorReport(); - - if (!selectedMember) { - return null; - } - - const { user, spouse } = selectedMember; - const { - preferredName, - lastName, - personNumber, - staffAccountID, - userPersonType, - team, - } = user; - const initials = getInitials(preferredName, lastName); - const fullName = `${preferredName} ${lastName}`; - - return ( - ({ - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(2), - p: theme.spacing(3), - height: '100%', - width: '100%', - })} - > - - - {initials} - - - {fullName} - - - - - - - - - - - - - {spouse && ( - - - {t('Spouse')}: {`${spouse.preferredName} ${spouse.lastName}`} - - - - - )} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; diff --git a/src/components/HrTools/MpdSupervisorReport/StaffMemberRow/StaffMember.test.tsx b/src/components/HrTools/MpdSupervisorReport/StaffMemberRow/StaffMember.test.tsx deleted file mode 100644 index 92b0018712..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/StaffMemberRow/StaffMember.test.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { currencyFormat } from 'src/lib/intlFormat'; -import theme from 'src/theme'; -import { EmployeeData, QuarterHealthEnum } from '../mockData'; -import { StaffMember } from './StaffMember'; - -const member: EmployeeData = { - user: { - id: '1', - preferredName: 'Brooke', - lastName: 'Butler', - personNumber: '10000001', - staffAccountID: '1000000001', - userPersonType: 'Full time', - team: 'FamilyLife', - }, - quarters: [ - { label: 'FQ4 25', health: QuarterHealthEnum.Green, payroll: 15000 }, - { label: 'FQ1 26', health: QuarterHealthEnum.Yellow, payroll: 16000 }, - { label: 'FQ2 26', health: QuarterHealthEnum.Red, payroll: 17000 }, - { label: 'FQ3 26', health: QuarterHealthEnum.Green, payroll: 18000 }, - ], -}; - -const renderRow = (onClick = jest.fn()) => { - render( - - - , - ); - return onClick; -}; - -describe('StaffMember', () => { - it('renders the staff member name as "{preferredName} {lastName}"', () => { - renderRow(); - expect(screen.getByText('Brooke Butler')).toBeInTheDocument(); - }); - - it('renders the staff account, employment type, and team line', () => { - renderRow(); - expect(screen.getByTestId('person-numbers')).toHaveTextContent( - '1000000001 · Full time · FamilyLife', - ); - }); - - it('renders a currency-formatted payroll chip for each quarter', () => { - renderRow(); - expect( - screen.getByText(currencyFormat(15000, 'USD', 'en-US')), - ).toBeInTheDocument(); - expect( - screen.getByText(currencyFormat(16000, 'USD', 'en-US')), - ).toBeInTheDocument(); - expect( - screen.getByText(currencyFormat(17000, 'USD', 'en-US')), - ).toBeInTheDocument(); - expect( - screen.getByText(currencyFormat(18000, 'USD', 'en-US')), - ).toBeInTheDocument(); - }); - - it('exposes an accessible button with a descriptive label', () => { - renderRow(); - expect( - screen.getByRole('button', { name: 'View details for Brooke Butler' }), - ).toBeInTheDocument(); - }); - - it('calls onClick when the card is clicked', async () => { - const onClick = renderRow(); - await userEvent.click( - screen.getByRole('button', { name: 'View details for Brooke Butler' }), - ); - expect(onClick).toHaveBeenCalledTimes(1); - }); -}); diff --git a/src/components/HrTools/MpdSupervisorReport/StaffMemberRow/StaffMember.tsx b/src/components/HrTools/MpdSupervisorReport/StaffMemberRow/StaffMember.tsx deleted file mode 100644 index f9365ab9ec..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/StaffMemberRow/StaffMember.tsx +++ /dev/null @@ -1,207 +0,0 @@ -import React, { useMemo } from 'react'; -import { - Avatar, - Box, - Card, - Chip, - Grid, - Stack, - Typography, -} from '@mui/material'; -import { styled } from '@mui/material/styles'; -import { TFunction } from 'i18next'; -import { useTranslation } from 'react-i18next'; -import { useLocale } from 'src/hooks/useLocale'; -import { currencyFormat } from 'src/lib/intlFormat'; -import theme from 'src/theme'; -import { getInitials, healthColor } from '../helpers'; -import { EmployeeData, QuarterHealthEnum } from '../mockData'; - -const healthLabel = (t: TFunction, health: QuarterHealthEnum): string => { - switch (health) { - case QuarterHealthEnum.Green: - return t('on track'); - case QuarterHealthEnum.Red: - return t('at risk'); - case QuarterHealthEnum.Yellow: - default: - return t('needs attention'); - } -}; - -const StyledCard = styled(Card)(({ theme }) => ({ - marginBottom: theme.spacing(1), - boxShadow: theme.shadows[1], - border: '1px solid', - borderColor: theme.palette.divider, - cursor: 'pointer', - width: '100%', -})); - -const GridItem = styled(Grid)(({ theme }) => ({ - display: 'flex', - alignItems: 'center', - gap: theme.spacing(2), - width: '100%', -})); - -const GridQuarter = styled(Grid)(({ theme }) => ({ - display: 'flex', - alignItems: 'center', - gap: theme.spacing(3), - width: '100%', - flexWrap: 'wrap', - justifyContent: 'flex-end', -})); - -const QuarterChip = styled(Chip, { - shouldForwardProp: (prop) => prop !== 'health', -})<{ health: QuarterHealthEnum }>(({ health }) => { - const { bg, color } = healthColor(theme, health); - return { - height: 22, - fontWeight: 600, - backgroundColor: bg, - color: color, - minWidth: '80px', - '& .MuiChip-label': { - paddingInline: theme.spacing(1), - }, - }; -}); - -interface StaffMemberProps { - data: EmployeeData; - onClick?: () => void; -} - -export const StaffMember: React.FC = ({ data, onClick }) => { - const { t } = useTranslation(); - const { user, quarters } = data; - const { - preferredName: name, - lastName, - staffAccountID, - userPersonType, - team, - } = user; - - const names = useMemo(() => { - if (!name || !lastName) { - return ''; - } - return `${name} ${lastName}`; - }, [name, lastName]); - - return ( - - - - - - {getInitials(name, lastName)} - - - - - - - - - - - - - - ); -}; - -interface FiscalYearQuartersProps { - quarters: EmployeeData['quarters']; -} -const FiscalYearQuartersBase: React.FC = ({ - quarters, -}) => { - const { t } = useTranslation(); - const locale = useLocale(); - return ( - - {quarters.map((quarter) => { - const amount = currencyFormat(quarter.payroll, 'USD', locale); - return ( - - ); - })} - - ); -}; -const FiscalYearQuarters = React.memo(FiscalYearQuartersBase); - -interface StaffInfoProps { - names: string; - staffAccountID: string; - userPersonType: string; - team: string; -} -const StaffInfoBase: React.FC = ({ - names, - staffAccountID, - userPersonType, - team, -}) => { - return ( - - - {names} - - {staffAccountID} - {' · '} - {userPersonType} - {' · '} - {team} - - - - ); -}; -const StaffInfo = React.memo(StaffInfoBase); diff --git a/src/components/HrTools/MpdSupervisorReport/helpers.test.ts b/src/components/HrTools/MpdSupervisorReport/helpers.test.ts deleted file mode 100644 index b1a28ed60d..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/helpers.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import theme from 'src/theme'; -import { getInitials, healthColor } from './helpers'; -import { QuarterHealthEnum } from './mockData'; - -describe('getInitials', () => { - it('returns the uppercased first letter of each name', () => { - expect(getInitials('Jane', 'Doe')).toBe('JD'); - }); - - it('lowercases input names to uppercase initials', () => { - expect(getInitials('jane', 'doe')).toBe('JD'); - }); - - it('handles a missing last name', () => { - expect(getInitials('Jane')).toBe('J'); - }); - - it('handles a missing first name', () => { - expect(getInitials(undefined, 'Doe')).toBe('D'); - }); - - it('returns an empty string when both names are missing', () => { - expect(getInitials()).toBe(''); - }); - - it('returns an empty string for empty-string names', () => { - expect(getInitials('', '')).toBe(''); - }); -}); - -describe('healthColor', () => { - it('returns green palette colors for Green', () => { - expect(healthColor(theme, QuarterHealthEnum.Green)).toEqual({ - bg: theme.palette.chipGreenLight.main, - color: theme.palette.chipGreenDark.main, - }); - }); - - it('returns red palette colors for Red', () => { - expect(healthColor(theme, QuarterHealthEnum.Red)).toEqual({ - bg: theme.palette.chipRedLight.main, - color: theme.palette.chipRedDark.main, - }); - }); - - it('returns yellow palette colors for Yellow', () => { - expect(healthColor(theme, QuarterHealthEnum.Yellow)).toEqual({ - bg: theme.palette.chipYellowLight.main, - color: theme.palette.chipYellowDark.main, - }); - }); -}); diff --git a/src/components/HrTools/MpdSupervisorReport/helpers.ts b/src/components/HrTools/MpdSupervisorReport/helpers.ts deleted file mode 100644 index a64f059420..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/helpers.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { Theme } from '@mui/material'; -import { QuarterHealthEnum } from './mockData'; - -/** - * Build avatar initials from a person's first and last name. - * Returns the uppercased first letter of each (e.g. "Jane Doe" -> "JD"). - */ -export const getInitials = (firstName?: string, lastName?: string): string => - ((firstName?.[0] ?? '') + (lastName?.[0] ?? '')).toUpperCase(); - -/** - * Map a quarter's MPD-health status to the chip background/foreground colors. - * Takes `theme` so callers can pass either the imported theme or the theme - * provided by an `sx` callback. - */ -export const healthColor = ( - theme: Theme, - health: QuarterHealthEnum, -): { bg: string; color: string } => { - switch (health) { - case QuarterHealthEnum.Green: - return { - bg: theme.palette.chipGreenLight.main, - color: theme.palette.chipGreenDark.main, - }; - case QuarterHealthEnum.Red: - return { - bg: theme.palette.chipRedLight.main, - color: theme.palette.chipRedDark.main, - }; - case QuarterHealthEnum.Yellow: - default: - return { - bg: theme.palette.chipYellowLight.main, - color: theme.palette.chipYellowDark.main, - }; - } -}; diff --git a/src/components/HrTools/MpdSupervisorReport/mockData.ts b/src/components/HrTools/MpdSupervisorReport/mockData.ts deleted file mode 100644 index e311e9bf6c..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/mockData.ts +++ /dev/null @@ -1,263 +0,0 @@ -export enum QuarterHealthEnum { - Green = 'green', - Yellow = 'yellow', - Red = 'red', -} - -export interface QuarterStatus { - /** e.g. "FQ4 25" */ - label: string; - health: QuarterHealthEnum; - payroll: number; -} - -export interface EmployeeData { - user: User; - spouse?: Spouse; - quarters: QuarterStatus[]; -} - -export interface User { - id: string; - preferredName: string; - lastName: string; - personNumber: string; - staffAccountID: string; - userPersonType: string; - team: string; -} - -export interface Spouse { - id: string; - preferredName: string; - lastName: string; - personNumber: string; - staffAccountID: string; -} - -const firstNames = [ - 'Brooke', - 'David', - 'Nathan', - 'Nick', - 'Sarah', - 'Michael', - 'Emily', - 'James', - 'Ashley', - 'Daniel', - 'Jessica', - 'Christopher', - 'Amanda', - 'Matthew', - 'Stephanie', - 'Joshua', - 'Lauren', - 'Andrew', - 'Rachel', - 'Ryan', - 'Megan', - 'Tyler', - 'Kayla', - 'Brandon', - 'Amber', - 'Justin', - 'Brittany', - 'Samuel', - 'Christina', - 'Jonathan', - 'Heather', - 'Kevin', - 'Danielle', - 'Eric', - 'Natalie', - 'Adam', - 'Melissa', - 'Steven', - 'Tiffany', - 'Kyle', - 'Alyssa', - 'Brian', - 'Kelly', - 'Timothy', - 'Amy', - 'Aaron', - 'Lindsey', - 'Patrick', - 'Jennifer', - 'Gregory', -]; - -const spouseFirstNames = [ - 'Karen', - 'Lisa', - 'Rebecca', - 'Allison', - 'Monica', - 'Anna', - 'Grace', - 'Claire', - 'Hannah', - 'Olivia', - 'Sophia', - 'Emma', - 'Ava', - 'Isabella', - 'Mia', - 'Charlotte', - 'Abigail', - 'Harper', - 'Evelyn', - 'Aria', - 'Ella', - 'Scarlett', - 'Victoria', - 'Madison', - 'Luna', - 'Chloe', - 'Penelope', - 'Layla', - 'Riley', - 'Zoey', - 'Nora', - 'Lily', - 'Eleanor', - 'Hannah', - 'Lillian', - 'Addison', - 'Aubrey', - 'Ellie', - 'Stella', - 'Natalia', - 'Zoe', - 'Leah', - 'Hazel', - 'Violet', - 'Aurora', - 'Savannah', - 'Audrey', - 'Brooklyn', - 'Bella', - 'Claire', -]; - -const lastNames = [ - 'Butler', - 'Henry', - 'Walden', - 'Bair', - 'Thompson', - 'Martinez', - 'Anderson', - 'Taylor', - 'Wilson', - 'Moore', - 'Jackson', - 'White', - 'Harris', - 'Martin', - 'Garcia', - 'Davis', - 'Lewis', - 'Robinson', - 'Clark', - 'Rodriguez', - 'Hernandez', - 'Walker', - 'Young', - 'Allen', - 'King', - 'Wright', - 'Scott', - 'Torres', - 'Nguyen', - 'Hill', - 'Flores', - 'Green', - 'Adams', - 'Nelson', - 'Baker', - 'Hall', - 'Rivera', - 'Campbell', - 'Mitchell', - 'Carter', - 'Roberts', - 'Phillips', - 'Evans', - 'Turner', - 'Torres', - 'Parker', - 'Collins', - 'Edwards', - 'Stewart', - 'Sanchez', -]; - -const teams = [ - 'FamilyLife', - 'Digital strategies', - 'Campus', - 'Athletes in Action', - 'Cru City', -]; - -const quarterLabels = ['FQ4 25', 'FQ1 26', 'FQ2 26', 'FQ3 26']; - -// Deterministic health pattern cycling through all three values -const healthCycle: QuarterHealthEnum[] = [ - QuarterHealthEnum.Green, - QuarterHealthEnum.Yellow, - QuarterHealthEnum.Red, - QuarterHealthEnum.Green, - QuarterHealthEnum.Yellow, - QuarterHealthEnum.Red, - QuarterHealthEnum.Green, - QuarterHealthEnum.Yellow, - QuarterHealthEnum.Red, -]; - -export const mockStaffMembers: EmployeeData[] = firstNames.map( - (firstName, i) => { - const hasSpouse = i % 2 === 0; - const lastName = lastNames[i % lastNames.length]; - const team = teams[i % teams.length]; - const personType = i % 3 === 0 ? 'Part time' : 'Full time'; - // Use large base numbers to keep personNumber/staffAccountID plausible - const personNumber = String(10000000 + i * 1234 + 557); - const staffAccountID = String(1000000000 + i * 5678 + 456); - - const quarters = quarterLabels.map((label, qi) => ({ - label, - health: healthCycle[(i + qi) % healthCycle.length], - payroll: 15000 + (((i * 4 + qi) * 7919) % 25001), - })); - - const entry: EmployeeData = { - user: { - id: `member-${i + 1}`, - preferredName: firstName, - lastName, - personNumber, - staffAccountID, - userPersonType: personType, - team, - }, - quarters, - }; - - if (hasSpouse) { - const spousePersonNumber = String(10000000 + i * 1234 + 558); - const spouseStaffAccountID = String(1000000000 + i * 5678 + 457); - entry.spouse = { - id: `spouse-${i + 1}`, - preferredName: spouseFirstNames[i % spouseFirstNames.length], - lastName, - personNumber: spousePersonNumber, - staffAccountID: spouseStaffAccountID, - }; - } - - return entry; - }, -); diff --git a/src/components/HrTools/MpdSupervisorReport/useMockInfiniteStaff.test.ts b/src/components/HrTools/MpdSupervisorReport/useMockInfiniteStaff.test.ts deleted file mode 100644 index 8ec1a138d7..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/useMockInfiniteStaff.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { EmployeeData, QuarterHealthEnum } from './mockData'; -import { useMockInfiniteStaff } from './useMockInfiniteStaff'; - -const makeItems = (count: number): EmployeeData[] => - Array.from({ length: count }, (_, i) => ({ - user: { - id: `member-${i + 1}`, - preferredName: `User${i + 1}`, - lastName: `Last${i + 1}`, - personNumber: String(10000000 + i), - staffAccountID: String(1000000000 + i), - userPersonType: 'Full time', - team: 'Campus', - }, - quarters: [ - { label: 'FQ4 25', health: QuarterHealthEnum.Green, payroll: 15000 }, - { label: 'FQ1 26', health: QuarterHealthEnum.Green, payroll: 15000 }, - { label: 'FQ2 26', health: QuarterHealthEnum.Green, payroll: 15000 }, - { label: 'FQ3 26', health: QuarterHealthEnum.Green, payroll: 15000 }, - ], - })); - -describe('useMockInfiniteStaff', () => { - it('returns first pageSize items and hasNextPage true when more exist', () => { - const items = makeItems(60); - const { result } = renderHook(() => useMockInfiniteStaff(items, 25)); - - expect(result.current.data.nodes).toHaveLength(25); - expect(result.current.data.pageInfo.hasNextPage).toBe(true); - expect(result.current.data.pageInfo.endCursor).toBe('25'); - expect(result.current.loading).toBe(false); - }); - - it('fetchMore appends the next page', () => { - const items = makeItems(60); - const { result } = renderHook(() => useMockInfiniteStaff(items, 25)); - - act(() => { - result.current.fetchMore(); - }); - - expect(result.current.data.nodes).toHaveLength(50); - expect(result.current.data.pageInfo.hasNextPage).toBe(true); - expect(result.current.data.pageInfo.endCursor).toBe('50'); - }); - - it('fetchMore past the end does not exceed allItems.length and sets hasNextPage false', () => { - const items = makeItems(30); - const { result } = renderHook(() => useMockInfiniteStaff(items, 25)); - - // First fetchMore loads all remaining items - act(() => { - result.current.fetchMore(); - }); - - expect(result.current.data.nodes).toHaveLength(30); - expect(result.current.data.pageInfo.hasNextPage).toBe(false); - - // Calling fetchMore again should be a no-op - act(() => { - result.current.fetchMore(); - }); - - expect(result.current.data.nodes).toHaveLength(30); - expect(result.current.data.pageInfo.hasNextPage).toBe(false); - }); - - it('resets to page 1 when allItems reference changes', () => { - const items = makeItems(60); - const { result, rerender } = renderHook( - ({ allItems }: { allItems: EmployeeData[] }) => - useMockInfiniteStaff(allItems, 25), - { initialProps: { allItems: items } }, - ); - - // Load page 2 - act(() => { - result.current.fetchMore(); - }); - expect(result.current.data.nodes).toHaveLength(50); - - // Replace with a new filtered array — should reset to page 1 - const filteredItems = makeItems(40); - rerender({ allItems: filteredItems }); - - expect(result.current.data.nodes).toHaveLength(25); - expect(result.current.data.pageInfo.hasNextPage).toBe(true); - }); - - it('reports hasNextPage false immediately when allItems is smaller than pageSize', () => { - const items = makeItems(10); - const { result } = renderHook(() => useMockInfiniteStaff(items, 25)); - - expect(result.current.data.nodes).toHaveLength(10); - expect(result.current.data.pageInfo.hasNextPage).toBe(false); - expect(result.current.data.pageInfo.endCursor).toBe('10'); - }); - - it('handles an empty array (drives the empty state)', () => { - const { result } = renderHook(() => useMockInfiniteStaff([], 25)); - - expect(result.current.data.nodes).toHaveLength(0); - expect(result.current.data.pageInfo.hasNextPage).toBe(false); - expect(result.current.data.pageInfo.endCursor).toBe('0'); - }); -}); diff --git a/src/components/HrTools/MpdSupervisorReport/useMockInfiniteStaff.ts b/src/components/HrTools/MpdSupervisorReport/useMockInfiniteStaff.ts deleted file mode 100644 index 8569e388ac..0000000000 --- a/src/components/HrTools/MpdSupervisorReport/useMockInfiniteStaff.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { EmployeeData } from './mockData'; - -interface StaffPageInfo { - endCursor: string; - hasNextPage: boolean; -} - -interface StaffConnection { - nodes: EmployeeData[]; - pageInfo: StaffPageInfo; -} - -interface UseMockInfiniteStaffResult { - data: StaffConnection; - // TODO(MPDX): replace with real Apollo fetchMore + loading once backend is wired. - loading: boolean; - fetchMore: () => void; -} - -export const useMockInfiniteStaff = ( - allItems: EmployeeData[], - pageSize = 25, -): UseMockInfiniteStaffResult => { - const [page, setPage] = useState(1); - - useEffect(() => { - setPage(1); - }, [allItems]); - - const data = useMemo(() => { - const nodes = allItems.slice(0, page * pageSize); - return { - nodes, - pageInfo: { - endCursor: String(nodes.length), - hasNextPage: nodes.length < allItems.length, - }, - }; - }, [allItems, page, pageSize]); - - const fetchMore = () => { - if (data.pageInfo.hasNextPage) { - setPage((p) => p + 1); - } - }; - - return { data, loading: false, fetchMore }; -}; diff --git a/src/components/HrTools/NsGoalCalculator/NextSteps/NextStepsStep.test.tsx b/src/components/HrTools/NsGoalCalculator/NextSteps/NextStepsStep.test.tsx deleted file mode 100644 index 2087997080..0000000000 --- a/src/components/HrTools/NsGoalCalculator/NextSteps/NextStepsStep.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import { NsGoalCalculatorTestWrapper } from '../NsGoalCalculatorTestWrapper'; -import { NextStepsStep } from './NextStepsStep'; - -const TestComponent: React.FC = () => ( - - - -); - -describe('NextStepsStep', () => { - it('renders the step title', () => { - const { getByRole } = render(); - - expect(getByRole('heading', { name: 'Next Steps' })).toBeInTheDocument(); - }); - - it('renders the completion text', () => { - const { getByText } = render(); - - expect( - getByText('Great job completing the MPD Goal Calculation process!'), - ).toBeInTheDocument(); - }); -}); diff --git a/src/components/HrTools/NsGoalCalculator/NextSteps/NextStepsStep.tsx b/src/components/HrTools/NsGoalCalculator/NextSteps/NextStepsStep.tsx index e9b92788c2..a6c99310f3 100644 --- a/src/components/HrTools/NsGoalCalculator/NextSteps/NextStepsStep.tsx +++ b/src/components/HrTools/NsGoalCalculator/NextSteps/NextStepsStep.tsx @@ -1,6 +1,5 @@ -import React from 'react'; import { Box, Typography } from '@mui/material'; -import { Trans, useTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; import { NsGoalCalculatorLayout } from '../Shared/NsGoalCalculatorLayout'; export const NextStepsStep: React.FC = () => { @@ -9,19 +8,10 @@ export const NextStepsStep: React.FC = () => { return ( + {t('Next Steps')} - - - {t('Great job completing the MPD Goal Calculation process!')} - - - - - You can return to this New Staff Goal Calculation under HR Tools - in your top navigation at any time. If you have any questions or - need to make changes to your goal, please contact your coach. - + + {t('This step is coming soon.')} } diff --git a/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/DebtPaymentField.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/DebtPaymentField.test.tsx new file mode 100644 index 0000000000..4e0956e378 --- /dev/null +++ b/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/DebtPaymentField.test.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { TFunction } from 'react-i18next'; +import * as yup from 'yup'; +import { DebtPaymentField } from './DebtPaymentField'; +import { getAmountSchema } from './FinancialDetails'; + +// In tests t() returns the key, so an identity function yields the English strings asserted below. +const t = ((key: string) => key) as unknown as TFunction; + +const requiredError = 'Please enter an amount, or 0 if you have none.'; +const guidance = + 'Round to the nearest dollar. Please enter 0 if you have none.'; +const question = + 'What is your monthly payment for all of your student loan debt?'; + +// Reuse the production amount validation so this test can never drift from the real schema. +const schema = yup.object({ payment: getAmountSchema(t) }); + +const TestComponent: React.FC = () => ( + } + /> +); + +describe('DebtPaymentField', () => { + it('renders the input with the question as its accessible name and placeholder', () => { + const { getByRole, getByPlaceholderText } = render(); + + expect(getByRole('spinbutton', { name: question })).toBeInTheDocument(); + expect(getByPlaceholderText(question)).toBeInTheDocument(); + }); + + it('renders the provided icon', () => { + const { getByTestId } = render(); + + expect(getByTestId('debt-icon')).toBeInTheDocument(); + }); + + it('shows the required error while empty', () => { + const { getByText } = render(); + + expect(getByText(requiredError)).toBeInTheDocument(); + }); + + it('shows rounding guidance once a valid amount is entered', () => { + const { getByRole, getByText, queryByText } = render(); + + userEvent.type(getByRole('spinbutton', { name: question }), '0'); + + expect(getByText(guidance)).toBeInTheDocument(); + expect(queryByText(requiredError)).not.toBeInTheDocument(); + }); + + it('rejects a negative amount', () => { + expect(() => getAmountSchema(t).validateSync('-5')).toThrow( + 'Please enter a positive amount.', + ); + }); + + it('rejects a non-whole-dollar amount', () => { + expect(() => getAmountSchema(t).validateSync('12.50')).toThrow( + 'Please enter a whole dollar amount.', + ); + }); + + it('accepts a whole-dollar amount and normalizes leading zeros', () => { + expect(getAmountSchema(t).validateSync('500')).toBe('500'); + expect(getAmountSchema(t).validateSync('007')).toBe('7'); + expect(getAmountSchema(t).validateSync('0')).toBe('0'); + }); +}); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/DebtPaymentField.tsx b/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/DebtPaymentField.tsx new file mode 100644 index 0000000000..147ce65184 --- /dev/null +++ b/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/DebtPaymentField.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { + FormControl, + FormHelperText, + InputAdornment, + OutlinedInput, +} from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import * as yup from 'yup'; +import { useQuestionnaireAutoSave } from '../Shared/useQuestionnaireAutoSave'; + +interface DebtPaymentFieldProps { + fieldName: string; + schema: yup.Schema; + debtType: string; + icon: React.ReactNode; +} + +/** + * A single whole-dollar monthly-payment input. Rendered only while the debt question is "Yes"; + * unmounting it (on "No") both discards its value and marks it valid so Continue unblocks. + */ +export const DebtPaymentField: React.FC = ({ + fieldName, + schema, + debtType, + icon, +}) => { + const { t } = useTranslation(); + const { error, helperText, ...fieldProps } = useQuestionnaireAutoSave({ + fieldName, + schema, + }); + + const question = t( + 'What is your monthly payment for all of your {{debtType}}?', + { + debtType, + }, + ); + + const helperTextId = `${fieldName}-helper-text`; + + return ( + + {icon} + } + placeholder={question} + sx={{ + 'input::placeholder': { + opacity: 0.7, + }, + }} + {...fieldProps} + /> + + + {error + ? helperText + : t('Round to the nearest dollar. Please enter 0 if you have none.')} + + + ); +}; diff --git a/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/FinancialDetails.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/FinancialDetails.test.tsx index 46783d8feb..c2e552d83f 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/FinancialDetails.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/FinancialDetails.test.tsx @@ -67,16 +67,11 @@ describe('FinancialDetails', () => { ).not.toBeInTheDocument(); }); - it('shows a required error on each payment field once it is touched', () => { + it('shows a required error on each empty payment field', () => { const { getByRole, getAllByText } = render(); userEvent.click(getByRole('radio', { name: 'Yes' })); - [studentLoanQuestion, carQuestion, creditCardQuestion].forEach((name) => { - getByRole('spinbutton', { name }).focus(); - userEvent.tab(); - }); - expect(getAllByText(requiredError)).toHaveLength(3); }); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/FinancialDetails.tsx b/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/FinancialDetails.tsx index 30550d385f..9c70e00b59 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/FinancialDetails.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/FinancialInformation/FinancialDetails.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; import CreditCard from '@mui/icons-material/CreditCard'; import DirectionsCar from '@mui/icons-material/DirectionsCar'; import School from '@mui/icons-material/School'; @@ -7,19 +7,29 @@ import { FormControlLabel, FormHelperText, FormLabel, - InputAdornment, Radio, RadioGroup, Stack, } from '@mui/material'; import { TFunction, useTranslation } from 'react-i18next'; import * as yup from 'yup'; -import { useOptionalAutosaveForm } from 'src/components/Shared/Autosave/AutosaveForm'; -import { NumberQuestion } from '../Shared/NumberQuestion'; -import { getAmountSchema } from '../Shared/helpers/getAmountSchema'; +import { useQuestionnaireAutoSave } from '../Shared/useQuestionnaireAutoSave'; +import { DebtPaymentField } from './DebtPaymentField'; + +export const getAmountSchema = (t: TFunction): yup.StringSchema => + yup + .string() + // Normalize leading zeros + .transform((value) => + typeof value === 'string' ? value.replace(/^0+(?=\d)/, '') : value, + ) + .matches(/^[^-]/, t('Please enter a positive amount.')) + .matches(/^\d+$/, t('Please enter a whole dollar amount.')) + .required(t('Please enter an amount, or 0 if you have none.')); export const getFinancialDetailsSchema = (t: TFunction) => yup.object({ + hasDebt: yup.string().required(t('Please select an answer.')), studentLoanPayment: getAmountSchema(t), carPayment: getAmountSchema(t), creditCardPayment: getAmountSchema(t), @@ -30,38 +40,18 @@ export const FinancialDetails: React.FC = () => { const schema = useMemo(() => getFinancialDetailsSchema(t), [t]); - // UI only toggle - const [hasDebt, setHasDebt] = useState(''); - const showDebtFields = hasDebt === 'Yes'; - const hasDebtError = !hasDebt; - - const { markValid, markInvalid } = useOptionalAutosaveForm() ?? {}; - useEffect(() => { - if (hasDebtError) { - markInvalid?.('hasDebt'); - } else { - markValid?.('hasDebt'); - } - return () => markValid?.('hasDebt'); - }, [hasDebtError, markValid, markInvalid]); + const { + value: hasDebt, + error: hasDebtError, + helperText: hasDebtHelperText, + ...hasDebtProps + } = useQuestionnaireAutoSave({ + fieldName: 'hasDebt', + schema, + saveOnChange: true, + }); - const debtFields = [ - { - fieldName: 'studentLoanPayment', - debtType: t('student loan debt'), - icon: , - }, - { - fieldName: 'carPayment', - debtType: t('car debt'), - icon: , - }, - { - fieldName: 'creditCardPayment', - debtType: t('credit card debt'), - icon: , - }, - ]; + const showDebtFields = hasDebt === 'Yes'; return ( @@ -74,34 +64,38 @@ export const FinancialDetails: React.FC = () => { sx={{ paddingInline: 2 }} aria-labelledby="has-debt-label" value={hasDebt} - onChange={(event) => setHasDebt(event.target.value)} + {...hasDebtProps} > } label={t('Yes')} /> } label={t('No')} /> - {hasDebtError && ( - {t('Please select an answer.')} + {hasDebtHelperText && ( + {hasDebtHelperText} )} - {showDebtFields && - debtFields.map(({ fieldName, debtType, icon }) => ( - + } + /> + } + /> + {icon} - } + debtType={t('credit card debt')} + icon={} /> - ))} + + )} ); }; diff --git a/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.test.tsx index 290e7f20b5..0738c1b482 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.test.tsx @@ -30,7 +30,9 @@ const TestComponent: React.FC = () => ( describe('MinistryDetails', () => { it('renders the four questions', async () => { - const { getByRole, findByRole } = render(); + const { getByRole, getByPlaceholderText, findByRole } = render( + , + ); expect( getByRole('combobox', { @@ -38,9 +40,9 @@ describe('MinistryDetails', () => { }), ).toBeInTheDocument(); expect( - getByRole('textbox', { - name: 'What is your expected ministry assignment location?', - }), + getByPlaceholderText( + 'What is your expected ministry assignment location?', + ), ).toBeInTheDocument(); expect( await findByRole('combobox', { @@ -54,16 +56,6 @@ describe('MinistryDetails', () => { ).toBeInTheDocument(); }); - it('marks the location field as required', () => { - const { getByRole } = render(); - - expect( - getByRole('textbox', { - name: 'What is your expected ministry assignment location?', - }), - ).toBeRequired(); - }); - it('offers the dummy ministry options', () => { const { getByRole } = render(); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.tsx b/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.tsx index 0816599d1f..d2bbbb7fca 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryDetails.tsx @@ -3,8 +3,14 @@ import LocationOn from '@mui/icons-material/LocationOn'; import MuseumSharp from '@mui/icons-material/MuseumSharp'; import { CircularProgress, + FormControl, + FormControlLabel, + FormHelperText, + FormLabel, InputAdornment, MenuItem, + Radio, + RadioGroup, Stack, TextField, Typography, @@ -12,7 +18,6 @@ import { import { useTranslation } from 'react-i18next'; import * as yup from 'yup'; import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; -import { RadioQuestion } from '../Shared/RadioQuestion'; import { useQuestionnaireAutoSave } from '../Shared/useQuestionnaireAutoSave'; // TODO(MPDX-9758): Replace with the real ministry list from OneApp. @@ -71,11 +76,20 @@ export const MinistryDetails: React.FC = () => { saveOnChange: true, }); + const { + error: assignmentTypeError, + helperText: assignmentTypeHelperText, + ...assignmentTypeProps + } = useQuestionnaireAutoSave({ + fieldName: 'assignmentType', + schema, + saveOnChange: true, + }); + return ( { @@ -113,7 +127,6 @@ export const MinistryDetails: React.FC = () => { ) : ( { )} - + + + {t('What type of assignment are you expecting?')} + + + } + label={t('Field')} + /> + } + label={t('Office')} + /> + + {assignmentTypeHelperText && ( + {assignmentTypeHelperText} + )} + ); }; diff --git a/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryInformation.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryInformation.test.tsx index 10d40cd419..efa95b7876 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryInformation.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/MinistryInformation/MinistryInformation.test.tsx @@ -12,7 +12,9 @@ const TestComponent: React.FC = () => ( describe('MinistryInformation', () => { it('keeps Continue disabled until all four fields are answered', async () => { - const { getByRole, findByRole } = render(); + const { getByRole, getByPlaceholderText, findByRole } = render( + , + ); const continueButton = getByRole('button', { name: 'Continue' }); expect(continueButton).toBeDisabled(); @@ -25,9 +27,9 @@ describe('MinistryInformation', () => { userEvent.click(getByRole('option', { name: 'Cru' })); userEvent.type( - getByRole('textbox', { - name: 'What is your expected ministry assignment location?', - }), + getByPlaceholderText( + 'What is your expected ministry assignment location?', + ), 'Orlando, FL', ); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx deleted file mode 100644 index 90ce6b0678..0000000000 --- a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.test.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import theme from 'src/theme'; -import { NsoDetails } from './NsoDetails'; - -const TestComponent: React.FC = () => ( - - - -); - -describe('NsoDetails', () => { - it('renders the housing question with all options', () => { - const { getByRole } = render(); - - expect( - getByRole('radiogroup', { - name: 'Which of the following describes your NSO housing?', - }), - ).toBeInTheDocument(); - expect( - getByRole('radio', { name: 'Single in hotel/dorm room' }), - ).toBeInTheDocument(); - expect( - getByRole('radio', { name: 'Sharing 2 in hotel/dorm room' }), - ).toBeInTheDocument(); - expect( - getByRole('radio', { name: 'Couple in hotel/dorm room' }), - ).toBeInTheDocument(); - expect( - getByRole('radio', { name: 'Family in a hotel/room' }), - ).toBeInTheDocument(); - expect( - getByRole('radio', { name: 'Local / Commuting' }), - ).toBeInTheDocument(); - }); - - it('renders the sessions question with both options', () => { - const { getByRole } = render(); - - expect( - getByRole('radiogroup', { - name: 'Which describes the sessions you are attending?', - }), - ).toBeInTheDocument(); - expect(getByRole('radio', { name: 'IBS and NSO' })).toBeInTheDocument(); - expect(getByRole('radio', { name: 'NSO' })).toBeInTheDocument(); - }); - - it('renders the special needs support and childcare fields', () => { - const { getByRole } = render(); - - expect( - getByRole('spinbutton', { - name: 'How much special needs support have you already received for NSO?', - }), - ).toBeInTheDocument(); - expect( - getByRole('spinbutton', { - name: 'If you are a parent with children in Childcare, please enter how many.', - }), - ).toBeInTheDocument(); - }); - - it('shows the required error on a numeric field once it is touched', () => { - const { getByRole, getByText } = render(); - - getByRole('spinbutton', { - name: 'How much special needs support have you already received for NSO?', - }).focus(); - userEvent.tab(); - - expect( - getByText('Please enter an amount, or 0 if you have none.'), - ).toBeInTheDocument(); - }); - - it('accepts 0 in the numeric fields', () => { - const { getByRole, queryByText } = render(); - - userEvent.type( - getByRole('spinbutton', { - name: 'How much special needs support have you already received for NSO?', - }), - '0', - ); - userEvent.type( - getByRole('spinbutton', { - name: 'If you are a parent with children in Childcare, please enter how many.', - }), - '0', - ); - - expect( - queryByText('Please enter an amount, or 0 if you have none.'), - ).not.toBeInTheDocument(); - expect( - queryByText('Please enter a number, or 0 if you have none.'), - ).not.toBeInTheDocument(); - }); -}); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx deleted file mode 100644 index 222f32f98f..0000000000 --- a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoDetails.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import React, { useMemo } from 'react'; -import { Stack } from '@mui/material'; -import { TFunction, useTranslation } from 'react-i18next'; -import * as yup from 'yup'; -import { NumberQuestion } from '../Shared/NumberQuestion'; -import { RadioOption, RadioQuestion } from '../Shared/RadioQuestion'; -import { getAmountSchema } from '../Shared/helpers/getAmountSchema'; -import { getWholeNumberSchema } from '../Shared/helpers/getWholeNumberSchema'; - -export const getNsoDetailsSchema = (t: TFunction) => - yup.object({ - nsoHousing: yup.string().required(t('Please select an answer.')), - nsoSessions: yup.string().required(t('Please select an answer.')), - specialNeedsSupport: getAmountSchema(t), - childcareChildren: getWholeNumberSchema( - t, - t('Please enter a number, or 0 if you have none.'), - ), - }); - -export const NsoDetails: React.FC = () => { - const { t } = useTranslation(); - - const schema = useMemo(() => getNsoDetailsSchema(t), [t]); - - const housingOptions: RadioOption[] = [ - { - value: 'Single in hotel/dorm room', - label: t('Single in hotel/dorm room'), - }, - { - value: 'Sharing 2 in hotel/dorm room', - label: t('Sharing 2 in hotel/dorm room'), - }, - { - value: 'Couple in hotel/dorm room', - label: t('Couple in hotel/dorm room'), - }, - { value: 'Family in a hotel/room', label: t('Family in a hotel/room') }, - { value: 'Local / Commuting', label: t('Local / Commuting') }, - ]; - - const sessionOptions: RadioOption[] = [ - { value: 'IBS and NSO', label: t('IBS and NSO') }, - { value: 'NSO', label: t('NSO') }, - ]; - - return ( - - - - - - - - - - ); -}; diff --git a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoInformation.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoInformation.test.tsx deleted file mode 100644 index 72217f69c0..0000000000 --- a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoInformation.test.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { NsoMpdQuestionnaireTestWrapper } from '../NsoMpdQuestionnaireTestWrapper'; -import { NsoInformation } from './NsoInformation'; - -const TestComponent: React.FC = () => ( - - - -); - -describe('NsoInformation', () => { - it('renders the heading and intro', () => { - const { getByRole } = render(); - - expect( - getByRole('heading', { name: 'NSO Information' }), - ).toBeInTheDocument(); - }); - - it('keeps Continue disabled until all four questions are answered', () => { - const { getByRole } = render(); - - const continueButton = getByRole('button', { name: 'Continue' }); - expect(continueButton).toBeDisabled(); - - userEvent.click(getByRole('radio', { name: 'Single in hotel/dorm room' })); - expect(continueButton).toBeDisabled(); - - userEvent.click(getByRole('radio', { name: 'NSO' })); - expect(continueButton).toBeDisabled(); - - userEvent.type( - getByRole('spinbutton', { - name: 'How much special needs support have you already received for NSO?', - }), - '500', - ); - expect(continueButton).toBeDisabled(); - - userEvent.type( - getByRole('spinbutton', { - name: 'If you are a parent with children in Childcare, please enter how many.', - }), - '0', - ); - expect(continueButton).toBeEnabled(); - }); -}); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoInformation.tsx b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoInformation.tsx index 04d39a00d5..f0b92a9c72 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoInformation.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/NsoInformation/NsoInformation.tsx @@ -1,9 +1,8 @@ import React from 'react'; -import { Box, Stack, Typography } from '@mui/material'; +import { Box, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { StepPage } from '../Shared/StepPage'; import { SubStep } from '../Shared/SubStepList'; -import { NsoDetails } from './NsoDetails'; export const NsoInformation: React.FC = () => { const { t } = useTranslation(); @@ -15,15 +14,9 @@ export const NsoInformation: React.FC = () => { return ( - - {t('NSO Information')} - - {t( - 'Tell us about your lodging while attending New Staff Orientation.', - )} - - - + + {t('This step is coming soon.')} + ); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/PersonalInformation/ContactInformation.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/PersonalInformation/ContactInformation.test.tsx index 944f0cb1d1..61e664e929 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/PersonalInformation/ContactInformation.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/PersonalInformation/ContactInformation.test.tsx @@ -20,12 +20,6 @@ describe('ContactInformation', () => { ).toBeInTheDocument(); }); - it('marks the cell phone number field as required', () => { - const { getByRole } = render(); - - expect(getByRole('textbox', { name: 'Cell Phone Number' })).toBeRequired(); - }); - it('strips disallowed characters as the user types', () => { const { getByRole } = render(); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/PersonalInformation/ContactInformation.tsx b/src/components/HrTools/NsoMpdQuestionnaire/PersonalInformation/ContactInformation.tsx index 7633eca762..518a08a11c 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/PersonalInformation/ContactInformation.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/PersonalInformation/ContactInformation.tsx @@ -29,7 +29,6 @@ export const ContactInformation: React.FC = () => { {t('Contact Information')} {t('Please provide your cell phone number.')} = ({ - startAdornment, -}) => ( - - - -); - -describe('NumberQuestion', () => { - it('renders the numeric input', () => { - const { getByRole } = render(); - - expect(getByRole('spinbutton', { name: 'How many?' })).toBeInTheDocument(); - }); - - it('marks the field as required', () => { - const { getByRole } = render(); - - expect(getByRole('spinbutton', { name: 'How many?' })).toBeRequired(); - }); - - it('shows the helper text until the field is touched', () => { - const { getByText, queryByText } = render(); - - expect(getByText('Enter 0 if none.')).toBeInTheDocument(); - expect(queryByText('Please enter a number.')).not.toBeInTheDocument(); - }); - - it('replaces the helper text with the validation error once touched while empty', () => { - const { getByRole, getByText, queryByText } = render(); - - getByRole('spinbutton', { name: 'How many?' }).focus(); - userEvent.tab(); - - expect(getByText('Please enter a number.')).toBeInTheDocument(); - expect(queryByText('Enter 0 if none.')).not.toBeInTheDocument(); - }); - - it('shows the validation error for an invalid, non-empty value once touched', () => { - const { getByRole, getByText, queryByText } = render(); - - userEvent.type(getByRole('spinbutton', { name: 'How many?' }), '-5'); - userEvent.tab(); - - expect(getByText('Please enter a whole number.')).toBeInTheDocument(); - expect(queryByText('Please enter a number.')).not.toBeInTheDocument(); - expect(queryByText('Enter 0 if none.')).not.toBeInTheDocument(); - }); - - it('links the input to its helper text via aria-describedby', () => { - const { getByRole, getByText } = render(); - - const input = getByRole('spinbutton', { name: 'How many?' }); - input.focus(); - userEvent.tab(); - - const describedBy = input.getAttribute('aria-describedby'); - - expect(describedBy).toBeTruthy(); - expect(getByText('Please enter a number.')).toHaveAttribute( - 'id', - describedBy, - ); - }); - - it('restores the helper text once a valid value is entered', () => { - const { getByRole, getByText, queryByText } = render(); - - userEvent.type(getByRole('spinbutton', { name: 'How many?' }), '0'); - - expect(getByText('Enter 0 if none.')).toBeInTheDocument(); - expect(queryByText('Please enter a number.')).not.toBeInTheDocument(); - }); - - it('renders a provided start adornment', () => { - const { getByTestId } = render( - } />, - ); - - expect(getByTestId('adornment')).toBeInTheDocument(); - }); -}); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/NumberQuestion.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/NumberQuestion.tsx deleted file mode 100644 index bdb0fc284f..0000000000 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/NumberQuestion.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from 'react'; -import { TextField } from '@mui/material'; -import * as yup from 'yup'; -import { useQuestionnaireAutoSave } from './useQuestionnaireAutoSave'; - -interface NumberQuestionProps { - fieldName: string; - schema: yup.Schema; - question: string; - helperText: string; - /** Optional leading adornment rendered inside the input. */ - startAdornment?: React.ReactNode; -} - -/** - * A single whole-number input wired to {@link useQuestionnaireAutoSave}. Saves on blur and replaces - * the helper text with the schema's validation message while the value is invalid. - */ -export const NumberQuestion: React.FC = ({ - fieldName, - schema, - question, - helperText, - startAdornment, -}) => { - const { - error, - helperText: errorText, - ...fieldProps - } = useQuestionnaireAutoSave({ fieldName, schema }); - - return ( - - ); -}; diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.test.tsx deleted file mode 100644 index 8cff296bea..0000000000 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.test.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { render } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import * as yup from 'yup'; -import theme from 'src/theme'; -import { RadioOption, RadioQuestion } from './RadioQuestion'; - -const schema = yup.object({ - choice: yup.string().required('Please select an answer.'), -}); -const options: RadioOption[] = [ - { value: 'A', label: 'Option A' }, - { value: 'B', label: 'Option B' }, -]; - -const TestComponent: React.FC<{ row?: boolean }> = ({ row }) => ( - - - -); - -describe('RadioQuestion', () => { - it('renders the label and each option', () => { - const { getByRole } = render(); - - expect(getByRole('radiogroup', { name: 'Pick one' })).toBeInTheDocument(); - expect(getByRole('radio', { name: 'Option A' })).toBeInTheDocument(); - expect(getByRole('radio', { name: 'Option B' })).toBeInTheDocument(); - }); - - it('marks the radio group as required', () => { - const { getByRole } = render(); - - expect(getByRole('radiogroup', { name: 'Pick one' })).toHaveAttribute( - 'aria-required', - 'true', - ); - }); - - it('shows the required error once the group is touched without a selection', () => { - const { getByRole, getByText } = render(); - - getByRole('radio', { name: 'Option A' }).focus(); - userEvent.tab(); - - expect(getByText('Please select an answer.')).toBeInTheDocument(); - }); - - it('clears the required error once an option is selected', () => { - const { getByRole, queryByText } = render(); - - userEvent.click(getByRole('radio', { name: 'Option A' })); - - expect(queryByText('Please select an answer.')).not.toBeInTheDocument(); - }); - - it('links the error to the radio group via aria-describedby', () => { - const { getByRole, getByText } = render(); - - getByRole('radio', { name: 'Option A' }).focus(); - userEvent.tab(); - - const describedBy = getByRole('radiogroup', { - name: 'Pick one', - }).getAttribute('aria-describedby'); - - expect(describedBy).toBeTruthy(); - expect(getByText('Please select an answer.')).toHaveAttribute( - 'id', - describedBy, - ); - }); - - it('lays options out in a row when row is set', () => { - const { getByRole } = render(); - - expect(getByRole('radiogroup', { name: 'Pick one' })).toHaveClass( - 'MuiFormGroup-row', - ); - }); -}); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx deleted file mode 100644 index 4576adbc6b..0000000000 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/RadioQuestion.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import React, { useId } from 'react'; -import { - FormControl, - FormControlLabel, - FormHelperText, - FormLabel, - Radio, - RadioGroup, -} from '@mui/material'; -import * as yup from 'yup'; -import { useQuestionnaireAutoSave } from './useQuestionnaireAutoSave'; - -export interface RadioOption { - value: string; - label: string; -} - -interface RadioQuestionProps { - fieldName: string; - schema: yup.Schema; - label: string; - options: RadioOption[]; - /** Lay the options out horizontally instead of stacked. */ - row?: boolean; -} - -/** - * A single required radio question wired to {@link useQuestionnaireAutoSave}. Saves on change and - * surfaces the schema's validation message as helper text while empty. - */ -export const RadioQuestion: React.FC = ({ - fieldName, - schema, - label, - options, - row = false, -}) => { - const labelId = useId(); - const helperTextId = useId(); - const { error, helperText, ...fieldProps } = useQuestionnaireAutoSave({ - fieldName, - schema, - saveOnChange: true, - }); - - return ( - - - {label} - - - {options.map((option) => ( - } - label={option.label} - /> - ))} - - {helperText && ( - {helperText} - )} - - ); -}; diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getAmountSchema.test.ts b/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getAmountSchema.test.ts deleted file mode 100644 index cad80e5213..0000000000 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getAmountSchema.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import i18n from 'src/lib/i18n'; -import { getAmountSchema } from './getAmountSchema'; - -describe('getAmountSchema', () => { - it('rejects a negative amount', () => { - expect(() => getAmountSchema(i18n.t).validateSync('-5')).toThrow( - 'Please enter a positive amount.', - ); - }); - - it('rejects a non-whole-dollar amount', () => { - expect(() => getAmountSchema(i18n.t).validateSync('12.50')).toThrow( - 'Please enter a whole dollar amount.', - ); - }); - - it('accepts a whole-dollar amount and normalizes leading zeros', () => { - expect(getAmountSchema(i18n.t).validateSync('500')).toBe('500'); - expect(getAmountSchema(i18n.t).validateSync('007')).toBe('7'); - expect(getAmountSchema(i18n.t).validateSync('0')).toBe('0'); - }); -}); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getAmountSchema.ts b/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getAmountSchema.ts deleted file mode 100644 index 6e44135a1e..0000000000 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getAmountSchema.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { TFunction } from 'react-i18next'; -import * as yup from 'yup'; -import { getWholeNumberSchema } from './getWholeNumberSchema'; - -/** - * Yup schema for a required, non-negative whole-dollar amount (no cents). Built on - * {@link getWholeNumberSchema} with dollar-specific validation copy. - */ -export const getAmountSchema = (t: TFunction): yup.StringSchema => - getWholeNumberSchema(t, t('Please enter an amount, or 0 if you have none.'), { - positive: t('Please enter a positive amount.'), - whole: t('Please enter a whole dollar amount.'), - }); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getWholeNumberSchema.test.ts b/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getWholeNumberSchema.test.ts deleted file mode 100644 index 02467d70c5..0000000000 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getWholeNumberSchema.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import i18n from 'src/lib/i18n'; -import { getWholeNumberSchema } from './getWholeNumberSchema'; - -const requiredMessage = 'Please enter a number, or 0 if you have none.'; - -describe('getWholeNumberSchema', () => { - it('requires a value', () => { - expect(() => - getWholeNumberSchema(i18n.t, requiredMessage).validateSync(undefined), - ).toThrow(requiredMessage); - }); - - it('rejects a negative number', () => { - expect(() => - getWholeNumberSchema(i18n.t, requiredMessage).validateSync('-5'), - ).toThrow('Please enter a positive number.'); - }); - - it('rejects a non-whole number', () => { - expect(() => - getWholeNumberSchema(i18n.t, requiredMessage).validateSync('12.5'), - ).toThrow('Please enter a whole number.'); - }); - - it('accepts a whole number and normalizes leading zeros', () => { - const schema = getWholeNumberSchema(i18n.t, requiredMessage); - - expect(schema.validateSync('500')).toBe('500'); - expect(schema.validateSync('007')).toBe('7'); - expect(schema.validateSync('0')).toBe('0'); - }); - - it('uses overridden positive and whole messages', () => { - const schema = getWholeNumberSchema(i18n.t, requiredMessage, { - positive: 'Please enter a positive amount.', - whole: 'Please enter a whole dollar amount.', - }); - - expect(() => schema.validateSync('-5')).toThrow( - 'Please enter a positive amount.', - ); - expect(() => schema.validateSync('12.50')).toThrow( - 'Please enter a whole dollar amount.', - ); - }); -}); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getWholeNumberSchema.ts b/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getWholeNumberSchema.ts deleted file mode 100644 index 5a89f15426..0000000000 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/helpers/getWholeNumberSchema.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { TFunction } from 'react-i18next'; -import * as yup from 'yup'; - -interface WholeNumberMessages { - /** Overrides the "positive" validation message (e.g. "Please enter a positive amount."). */ - positive?: string; - /** Overrides the "whole" validation message (e.g. "Please enter a whole dollar amount."). */ - whole?: string; -} - -/** - * yup schema for a required, non-negative whole number. Leading zeros - * are normalized. Callers supply the required message and may override the - * positive/whole validation messages for domain-specific copy. - */ -export const getWholeNumberSchema = ( - t: TFunction, - requiredMessage: string, - messages: WholeNumberMessages = {}, -): yup.StringSchema => - yup - .string() - // Normalize leading zeros - .transform((value) => - typeof value === 'string' ? value.replace(/^0+(?=\d)/, '') : value, - ) - .matches(/^[^-]/, messages.positive ?? t('Please enter a positive number.')) - .matches(/^\d+$/, messages.whole ?? t('Please enter a whole number.')) - .required(requiredMessage); diff --git a/src/components/HrTools/NsoMpdQuestionnaire/Shared/useQuestionnaireAutoSave.test.tsx b/src/components/HrTools/NsoMpdQuestionnaire/Shared/useQuestionnaireAutoSave.test.tsx index 48596eaa91..64acd37ea4 100644 --- a/src/components/HrTools/NsoMpdQuestionnaire/Shared/useQuestionnaireAutoSave.test.tsx +++ b/src/components/HrTools/NsoMpdQuestionnaire/Shared/useQuestionnaireAutoSave.test.tsx @@ -32,11 +32,10 @@ describe('useQuestionnaireAutoSave', () => { expect(input).toHaveValue('1234567890'); }); - it('surfaces the schema validation error for an invalid value once touched', () => { + it('surfaces the schema validation error for an invalid value', () => { const { getByRole, getByText } = render(); userEvent.type(getByRole('textbox', { name: 'Field' }), '123'); - userEvent.tab(); expect(getByText('Too short')).toBeInTheDocument(); }); diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx index 90be5f62c9..c602db9096 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.test.tsx @@ -84,19 +84,16 @@ describe('SetupStep', () => { await waitFor(() => expect(goalNameInput).toHaveValue('Test Goal')); userEvent.clear(goalNameInput); - userEvent.tab(); const payRateInput = await findByRole('spinbutton', { name: 'Hourly Pay Rate', }); userEvent.clear(payRateInput); - userEvent.tab(); const hoursInput = await findByRole('spinbutton', { name: 'Hours Worked', }); userEvent.clear(hoursInput); - userEvent.tab(); expect( await findByText('Goal Name is a required field'), diff --git a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx index c4a173c439..db2d34f45c 100644 --- a/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Setup/SetupStep.tsx @@ -17,10 +17,6 @@ import { import { useTheme } from '@mui/material/styles'; import { useTranslation } from 'react-i18next'; import * as yup from 'yup'; -import { - CurrencyAdornment, - PercentageAdornment, -} from 'src/components/HrTools/Shared/Adornments'; import { useGetUserQuery } from 'src/components/User/GetUser.generated'; import { DesignationSupportFormType, @@ -30,6 +26,10 @@ import { import { useGoalCalculatorConstants } from 'src/hooks/useGoalCalculatorConstants'; import { useLocale } from 'src/hooks/useLocale'; import { percentageFormat } from 'src/lib/intlFormat'; +import { + CurrencyAdornment, + PercentageAdornment, +} from '../../GoalCalculator/Shared/Adornments'; import { AutosaveTextField } from '../Shared/Autosave/AutosaveTextField'; import { useSaveField } from '../Shared/Autosave/useSaveField'; import { usePdsGoalCalculator } from '../Shared/PdsGoalCalculatorContext'; diff --git a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.test.tsx b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.test.tsx index 3ef0657fbb..0e25476cd0 100644 --- a/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.test.tsx +++ b/src/components/HrTools/PdsGoalCalculator/Shared/Autosave/AutosaveTextField.test.tsx @@ -114,7 +114,6 @@ describe('AutosaveTextField', () => { userEvent.clear(input); userEvent.type(input, '-100'); - userEvent.tab(); expect(input).toHaveAccessibleDescription( 'Pay Rate must be a positive number', @@ -235,7 +234,6 @@ describe('AutosaveTextField', () => { userEvent.clear(input); userEvent.type(input, '-100'); - userEvent.tab(); expect(input).toHaveAccessibleDescription( 'Pay Rate must be a positive number', @@ -261,17 +259,11 @@ describe('AutosaveTextField', () => { , ); - it('defers the validation error for an empty required field until touched', async () => { + it('shows validation error for an empty required field on load', async () => { const { findByRole } = renderRequired(); const input = await findByRole('textbox', { name: 'Goal Name' }); - await waitFor(() => expect(input).toBeEnabled()); - - expect(input).toHaveAccessibleDescription('Enter the goal name'); - expect(input).not.toHaveAttribute('aria-invalid', 'true'); - - input.focus(); - userEvent.tab(); + await waitFor(() => expect(input).toHaveValue('')); await waitFor(() => expect(input).toHaveAccessibleDescription('Goal Name is required'), @@ -283,10 +275,6 @@ describe('AutosaveTextField', () => { const { findByRole } = renderRequired(); const input = await findByRole('textbox', { name: 'Goal Name' }); - await waitFor(() => expect(input).toBeEnabled()); - - input.focus(); - userEvent.tab(); await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'), ); diff --git a/src/components/HrTools/SalaryCalculator/Receipt/Receipt.tsx b/src/components/HrTools/SalaryCalculator/Receipt/Receipt.tsx index 5cb94958c8..eecd40a41e 100644 --- a/src/components/HrTools/SalaryCalculator/Receipt/Receipt.tsx +++ b/src/components/HrTools/SalaryCalculator/Receipt/Receipt.tsx @@ -12,8 +12,6 @@ import { import { Trans, useTranslation } from 'react-i18next'; import { ProgressiveApprovalTierReasonEnum } from 'src/graphql/types.generated'; import { useAccountListId } from 'src/hooks/useAccountListId'; -import theme from 'src/theme'; -import { progressiveApprovalsLink } from '../../AdditionalSalaryRequest/Shared/pdfLinks'; import { useCaps } from '../SalaryCalculation/useCaps'; import { useSalaryCalculator } from '../SalaryCalculatorContext/SalaryCalculatorContext'; import { useFormatters } from '../Shared/useFormatters'; @@ -78,17 +76,8 @@ export const ReceiptStep: React.FC = () => { needs to be signed off by the{' '} {{ approver: progressiveApprovalTier.approver }}. This may affect your selected effective date. We will review your request through - our{' '} - - Progressive Approvals - {' '} - process and notify you of any changes to the status of this - request. + our Progressive Approvals process and notify you of any changes to + the status of this request. )} diff --git a/src/components/HrTools/SalaryCalculator/SalaryCalculation/RequestSummaryCard/RequestSummaryCard.tsx b/src/components/HrTools/SalaryCalculator/SalaryCalculation/RequestSummaryCard/RequestSummaryCard.tsx index dae12ec094..dff6b9ee55 100644 --- a/src/components/HrTools/SalaryCalculator/SalaryCalculation/RequestSummaryCard/RequestSummaryCard.tsx +++ b/src/components/HrTools/SalaryCalculator/SalaryCalculation/RequestSummaryCard/RequestSummaryCard.tsx @@ -1,4 +1,3 @@ -import Link from 'next/link'; import React, { useId } from 'react'; import InfoIcon from '@mui/icons-material/Info'; import { @@ -19,7 +18,6 @@ import { ProgressiveApprovalTierEnum, ProgressiveApprovalTierReasonEnum, } from 'src/graphql/types.generated'; -import { progressiveApprovalsLink } from '../../../AdditionalSalaryRequest/Shared/pdfLinks'; import { useSalaryCalculator } from '../../SalaryCalculatorContext/SalaryCalculatorContext'; import { StepCard } from '../../Shared/StepCard'; import { useFormatters } from '../../Shared/useFormatters'; @@ -120,15 +118,7 @@ export const RequestSummaryCard: React.FC = () => { Your {{ combined: combinedModifier }} Gross Requested Salary exceeds your{' '} {{ combined: combinedModifier }} Maximum Allowable Salary. Please make adjustments to your Salary Request above or fill out the Approval Process - Section below to request a higher amount through our{' '} - - Progressive Approvals - {' '} + Section below to request a higher amount through our Progressive Approvals process. This will take{' '} {{ timeframe: progressiveApprovalTier.approvalTimeframe }} as it needs to be signed off by the {{ approver: progressiveApprovalTier.approver }}. diff --git a/src/components/HrTools/SalaryCalculator/StepNavigation/useSubmitDialogContent.test.tsx b/src/components/HrTools/SalaryCalculator/StepNavigation/useSubmitDialogContent.test.tsx index 61191bfe0c..70ee13f889 100644 --- a/src/components/HrTools/SalaryCalculator/StepNavigation/useSubmitDialogContent.test.tsx +++ b/src/components/HrTools/SalaryCalculator/StepNavigation/useSubmitDialogContent.test.tsx @@ -1,9 +1,8 @@ -import { render, renderHook, waitFor } from '@testing-library/react'; +import { renderHook, waitFor } from '@testing-library/react'; import { ProgressiveApprovalTierEnum, ProgressiveApprovalTierReasonEnum, } from 'src/graphql/types.generated'; -import { progressiveApprovalsLink } from '../../AdditionalSalaryRequest/Shared/pdfLinks'; import { SalaryCalculatorTestWrapper } from '../SalaryCalculatorTestWrapper'; import { useSubmitDialogContent } from './useSubmitDialogContent'; @@ -87,16 +86,9 @@ describe('useSubmitDialogContent', () => { ); }); - const { container, getByRole } = render( -
{result.current.subContent}
, - ); - - expect(container).toHaveTextContent('$80,000.00'); - expect(container).toHaveTextContent('2-3 weeks'); - expect(container).toHaveTextContent('Vice President'); - expect( - getByRole('link', { name: 'Progressive Approvals' }), - ).toHaveAttribute('href', progressiveApprovalsLink); + expect(result.current.subContent).toContain('$80,000.00'); + expect(result.current.subContent).toContain('2-3 weeks'); + expect(result.current.subContent).toContain('Vice President'); }); it('returns board cap exception content when the reason is BoardCapException', async () => { diff --git a/src/components/HrTools/SalaryCalculator/StepNavigation/useSubmitDialogContent.tsx b/src/components/HrTools/SalaryCalculator/StepNavigation/useSubmitDialogContent.ts similarity index 68% rename from src/components/HrTools/SalaryCalculator/StepNavigation/useSubmitDialogContent.tsx rename to src/components/HrTools/SalaryCalculator/StepNavigation/useSubmitDialogContent.ts index 2ce8c247cc..816b12ffb3 100644 --- a/src/components/HrTools/SalaryCalculator/StepNavigation/useSubmitDialogContent.tsx +++ b/src/components/HrTools/SalaryCalculator/StepNavigation/useSubmitDialogContent.ts @@ -1,13 +1,8 @@ -import Link from 'next/link'; -import React from 'react'; -import { Box } from '@mui/material'; -import { Trans, useTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; import { ProgressiveApprovalTierEnum, ProgressiveApprovalTierReasonEnum, } from 'src/graphql/types.generated'; -import theme from 'src/theme'; -import { progressiveApprovalsLink } from '../../AdditionalSalaryRequest/Shared/pdfLinks'; import { useCaps } from '../SalaryCalculation/useCaps'; import { useSalaryCalculator } from '../SalaryCalculatorContext/SalaryCalculatorContext'; import { useFormatters } from '../Shared/useFormatters'; @@ -15,7 +10,7 @@ import { useFormatters } from '../Shared/useFormatters'; interface DialogContent { title: string; content: string; - subContent: React.ReactNode; + subContent: string; } export const useSubmitDialogContent = (): DialogContent => { @@ -41,7 +36,7 @@ export const useSubmitDialogContent = (): DialogContent => { let title = t( 'Your request requires additional approval because your Gross Salary exceeds your Maximum Allowable Salary. Do you want to continue?', ); - let subContent: React.ReactNode; + let subContent: string; if (reason === ProgressiveApprovalTierReasonEnum.BoardCapException) { subContent = t( "You have a Board approved Maximum Allowable Salary (CAP) and your salary request exceeds that amount. As a result we need to get their approval for this request. We'll forward your request to them and get back to you with their decision.", @@ -58,26 +53,13 @@ export const useSubmitDialogContent = (): DialogContent => { }, ); } else { - subContent = ( - - - We will review your request through our{' '} - - Progressive Approvals - {' '} - process. For the {{ amount: formatCurrency(combinedGross) }} you are - requesting, this will take{' '} - {{ timeframe: progressiveApprovalTier?.approvalTimeframe }} as it - needs to be signed off by{' '} - {{ approvers: progressiveApprovalTier?.approver }}. This may affect - your selected effective date. - - + subContent = t( + 'We will review your request through our Progressive Approvals process. For the {{amount}} you are requesting, this will take {{timeframe}} as it needs to be signed off by {{approvers}}. This may affect your selected effective date.', + { + amount: formatCurrency(combinedGross), + timeframe: progressiveApprovalTier?.approvalTimeframe, + approvers: progressiveApprovalTier?.approver, + }, ); } diff --git a/src/components/HrTools/SalaryCalculator/YourInformation/MaxAllowableSection/MaxAllowableSection.test.tsx b/src/components/HrTools/SalaryCalculator/YourInformation/MaxAllowableSection/MaxAllowableSection.test.tsx index 95b212b18e..75a65d3d37 100644 --- a/src/components/HrTools/SalaryCalculator/YourInformation/MaxAllowableSection/MaxAllowableSection.test.tsx +++ b/src/components/HrTools/SalaryCalculator/YourInformation/MaxAllowableSection/MaxAllowableSection.test.tsx @@ -122,7 +122,7 @@ describe('MaxAllowableSection', () => { }); it('warns when cap exceeds hard cap', async () => { - const { findByRole, findByText, getByRole } = render(); + const { findByRole, getByText, getByRole } = render(); userEvent.click( await findByRole('checkbox', { name: /Check if you prefer to split/ }), @@ -136,9 +136,7 @@ describe('MaxAllowableSection', () => { input.blur(); expect( - await findByText( - 'Maximum Allowable Salary must not exceed cap of $80,000', - ), + getByText('Maximum Allowable Salary must not exceed cap of $80,000'), ).toBeInTheDocument(); }); @@ -168,8 +166,8 @@ describe('MaxAllowableSection', () => { ); }); - it('shows required errors when split cap fields are empty and touched', async () => { - const { findByRole, findByText, getByRole } = render( + it('shows required errors when split cap fields are empty', async () => { + const { findByText } = render( { />, ); - const input = await findByRole('textbox', { - name: 'John Maximum Allowable Salary', - }); - await waitFor(() => expect(input).toBeEnabled()); - const spouseInput = getByRole('textbox', { - name: 'Jane Maximum Allowable Salary', - }); - - input.focus(); - userEvent.tab(); - spouseInput.focus(); - userEvent.tab(); - expect( await findByText('Maximum Allowable Salary is required'), ).toBeInTheDocument(); diff --git a/src/components/HrTools/SalaryCalculator/YourInformation/MhaRequestSection/MhaRequestSection.tsx b/src/components/HrTools/SalaryCalculator/YourInformation/MhaRequestSection/MhaRequestSection.tsx index 3dd01b3fa4..8913b053bd 100644 --- a/src/components/HrTools/SalaryCalculator/YourInformation/MhaRequestSection/MhaRequestSection.tsx +++ b/src/components/HrTools/SalaryCalculator/YourInformation/MhaRequestSection/MhaRequestSection.tsx @@ -228,6 +228,7 @@ export const MhaRequestSection: React.FC = () => { label={t('New Requested {{kind}}', { kind: userKind })} fieldName="mhaAmount" schema={schema} + required /> )} {showSpouseFields && ( @@ -235,6 +236,7 @@ export const MhaRequestSection: React.FC = () => { label={t('New Requested {{kind}}', { kind: spouseKind })} fieldName="spouseMhaAmount" schema={schema} + required /> )} diff --git a/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.test.tsx b/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.test.tsx index 3daf029066..09dea9cb8d 100644 --- a/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.test.tsx +++ b/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.test.tsx @@ -102,7 +102,7 @@ describe('BalanceCard', () => { />, ); - expect(getByTestId('Diversity1Icon')).toBeInTheDocument(); + expect(getByTestId('GroupsIcon')).toBeInTheDocument(); }); it('should display staff account icon', () => { diff --git a/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.tsx b/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.tsx index 58b06f88f8..bc3c61ca5c 100644 --- a/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.tsx +++ b/src/components/HrTools/SavingsFundTransfer/BalanceCard/BalanceCard.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Diversity1, Outbox, Savings, Wallet } from '@mui/icons-material'; +import { Groups, Outbox, Savings, Wallet } from '@mui/icons-material'; import { Box, Button, Card, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { SimpleScreenOnly } from 'src/components/Reports/styledComponents'; @@ -29,7 +29,7 @@ export const BalanceCard: React.FC = ({ ? Wallet : fund.fundType === FundTypeEnum.Savings ? Savings - : Diversity1; + : Groups; const iconBgColor = fund.fundType === FundTypeEnum.Primary ? theme.palette.chartOrange.main diff --git a/src/components/HrTools/Shared/AccountInfoBox/AccountInfoBox.test.tsx b/src/components/HrTools/Shared/AccountInfoBox/AccountInfoBox.test.tsx index 16197f4bf6..48ea668b27 100644 --- a/src/components/HrTools/Shared/AccountInfoBox/AccountInfoBox.test.tsx +++ b/src/components/HrTools/Shared/AccountInfoBox/AccountInfoBox.test.tsx @@ -3,57 +3,29 @@ import { render } from '@testing-library/react'; import { AccountInfoBox } from './AccountInfoBox'; describe('AccountInfoBox', () => { - it('renders name, accountId, and overallBalance when provided', () => { + it('renders name and accountId when provided', () => { const { getByTestId } = render( - , + , ); expect(getByTestId('account-info')).toBeInTheDocument(); expect(getByTestId('name')).toBeInTheDocument(); expect(getByTestId('account-id')).toBeInTheDocument(); - expect(getByTestId('InfoOutlinedIcon')).toBeInTheDocument(); - expect(getByTestId('overall-balance')).toBeInTheDocument(); }); - it('renders empty strings when name, accountId, and overallBalance are not provided', () => { - const { getByTestId, queryByTestId } = render(); + it('renders empty strings when name and accountId are not provided', () => { + const { getByTestId } = render(); expect(getByTestId('name').textContent).toBe(''); - expect(queryByTestId('account-id')).not.toBeInTheDocument(); - expect(queryByTestId('InfoOutlinedIcon')).not.toBeInTheDocument(); - expect(queryByTestId('overall-balance')).not.toBeInTheDocument(); + expect(getByTestId('account-id').textContent).toBe(''); }); - it('displays only name when accountId and overallBalance are not provided', () => { - const { getByTestId, queryByTestId } = render( - , - ); + it('displays only name when accountId is not provided', () => { + const { getByTestId } = render(); expect(getByTestId('name').textContent).toBe('Only Name'); - expect(queryByTestId('account-id')).not.toBeInTheDocument(); - expect(queryByTestId('InfoOutlinedIcon')).not.toBeInTheDocument(); + expect(getByTestId('account-id').textContent).toBe(''); }); - it('renders only accountId when name and overallBalance are not provided', () => { + it('renders only accountId when name is not provided', () => { const { getByTestId } = render(); expect(getByTestId('account-id').textContent).toBe('OnlyId'); - expect(getByTestId('InfoOutlinedIcon')).toBeInTheDocument(); - expect(getByTestId('name').textContent).toBe(''); - }); - - it('renders only overallBalance when name and accountId are not provided', () => { - const { getByTestId, queryByTestId } = render( - , - ); - expect(getByTestId('overall-balance').textContent).toBe('$1,000.00'); - expect(getByTestId('name').textContent).toBe(''); - expect(queryByTestId('account-id')).not.toBeInTheDocument(); - expect(queryByTestId('InfoOutlinedIcon')).not.toBeInTheDocument(); - }); - - it('renders zero overallBalance correctly', () => { - const { getByTestId } = render(); - expect(getByTestId('overall-balance').textContent).toBe('$0.00'); }); }); diff --git a/src/components/HrTools/Shared/AccountInfoBox/AccountInfoBox.tsx b/src/components/HrTools/Shared/AccountInfoBox/AccountInfoBox.tsx index 94e255bf83..52244a365a 100644 --- a/src/components/HrTools/Shared/AccountInfoBox/AccountInfoBox.tsx +++ b/src/components/HrTools/Shared/AccountInfoBox/AccountInfoBox.tsx @@ -1,66 +1,23 @@ import React from 'react'; -import { InfoOutlined } from '@mui/icons-material'; -import { Box, Tooltip, Typography } from '@mui/material'; -import { Trans, useTranslation } from 'react-i18next'; -import { SimpleScreenOnly } from 'src/components/Reports/styledComponents'; -import { useLocale } from 'src/hooks/useLocale'; -import { currencyFormat } from 'src/lib/intlFormat'; +import { Box, Typography } from '@mui/material'; interface AccountInfoBoxProps { name?: string; accountId?: string; - overallBalance?: number; } export const AccountInfoBox: React.FC = ({ name, accountId, - overallBalance, -}) => { - const { t } = useTranslation(); - const locale = useLocale(); - const currency = 'USD'; - - const title = ( - - Your Person Number is unique and assigned to you by Oracle HCM, Cru's - new HR system. It replaces the Employee ID (EMPLID) previously used in - PeopleSoft. If you need help with anything related to HR or payroll — - salary calculations, housing allowance, or additional salary requests — - this is the number HR staff will use to look you up in the system. - - ); - - return ( - - {name} - {accountId && ( - - {accountId} - - - - - - - )} - {overallBalance !== undefined && ( - - {currencyFormat(overallBalance, currency, locale, { - showTrailingZeros: true, - })} - - )} - - ); -}; +}) => ( + + {name} + {accountId} + +); diff --git a/src/components/HrTools/Shared/AccountInfoBox/AccountInfoBoxSkeleton.test.tsx b/src/components/HrTools/Shared/AccountInfoBox/AccountInfoBoxSkeleton.test.tsx index c73d6d5de8..e79f4f36eb 100644 --- a/src/components/HrTools/Shared/AccountInfoBox/AccountInfoBoxSkeleton.test.tsx +++ b/src/components/HrTools/Shared/AccountInfoBox/AccountInfoBoxSkeleton.test.tsx @@ -17,11 +17,4 @@ describe('AccountInfoBoxSkeleton', () => { const { getByTestId } = render(); expect(getByTestId('account-id-skeleton')).toBeInTheDocument(); }); - - it('renders the overall balance skeleton when provided', () => { - const { getByTestId } = render( - , - ); - expect(getByTestId('overall-balance-skeleton')).toBeInTheDocument(); - }); }); diff --git a/src/components/HrTools/Shared/AccountInfoBox/AccountInfoBoxSkeleton.tsx b/src/components/HrTools/Shared/AccountInfoBox/AccountInfoBoxSkeleton.tsx index e4f3793a84..c440e316a8 100644 --- a/src/components/HrTools/Shared/AccountInfoBox/AccountInfoBoxSkeleton.tsx +++ b/src/components/HrTools/Shared/AccountInfoBox/AccountInfoBoxSkeleton.tsx @@ -16,13 +16,7 @@ const StyledSkeletonBox = styled(Box)(({ theme }) => ({ borderRadius: theme.spacing(0.5), })); -interface AccountInfoBoxSkeletonProps { - hasOverallBalance?: boolean; -} - -export const AccountInfoBoxSkeleton: React.FC = ({ - hasOverallBalance, -}) => ( +export const AccountInfoBoxSkeleton: React.FC = () => ( @@ -30,10 +24,5 @@ export const AccountInfoBoxSkeleton: React.FC = ({ - {hasOverallBalance && ( - - - - )} ); diff --git a/src/lib/functions/getLocalizedAssignmentStatus.test.ts b/src/components/HrTools/Shared/Helpers/getLocalizedAssignmentStatus.test.ts similarity index 100% rename from src/lib/functions/getLocalizedAssignmentStatus.test.ts rename to src/components/HrTools/Shared/Helpers/getLocalizedAssignmentStatus.test.ts diff --git a/src/lib/functions/getLocalizedAssignmentStatus.ts b/src/components/HrTools/Shared/Helpers/getLocalizedAssignmentStatus.ts similarity index 100% rename from src/lib/functions/getLocalizedAssignmentStatus.ts rename to src/components/HrTools/Shared/Helpers/getLocalizedAssignmentStatus.ts diff --git a/src/components/Reports/MPGAIncomeExpensesReport/CustomToolbar/CustomToolbar.tsx b/src/components/Reports/MPGAIncomeExpensesReport/CustomToolbar/CustomToolbar.tsx index 61704ea063..1e9b5dc6c3 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/CustomToolbar/CustomToolbar.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/CustomToolbar/CustomToolbar.tsx @@ -1,3 +1,4 @@ +import FileDownloadIcon from '@mui/icons-material/FileDownload'; import FilterListIcon from '@mui/icons-material/FilterList'; import ViewColumnIcon from '@mui/icons-material/ViewColumn'; import { Box, Divider, Tooltip } from '@mui/material'; @@ -9,14 +10,25 @@ import { ToolbarButton, } from '@mui/x-data-grid'; import { useTranslation } from 'react-i18next'; +import { useLocale } from 'src/hooks/useLocale'; +import { exportToCsv } from '../CustomExport/CustomExport'; +import { ReportTypeEnum } from '../Helper/MPGAReportEnum'; import { TableCardHead } from '../Tables/TableCardHead'; +import { DataFields } from '../mockData'; interface CustomToolbarProps { + data: DataFields[]; + type: ReportTypeEnum; months: string[]; } -export const CustomToolbar: React.FC = ({ months }) => { +export const CustomToolbar: React.FC = ({ + data, + type, + months, +}) => { const { t } = useTranslation(); + const locale = useLocale(); return ( @@ -60,6 +72,11 @@ export const CustomToolbar: React.FC = ({ months }) => { sx={{ mx: 0.5, height: 30, alignSelf: 'center' }} /> + + exportToCsv(data, type, months, locale)}> + + + diff --git a/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.test.tsx deleted file mode 100644 index 587d02982d..0000000000 --- a/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import React from 'react'; -import { ThemeProvider } from '@mui/material/styles'; -import { render, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import theme from 'src/theme'; -import { exportToCsv } from '../CustomExport/CustomExport'; -import { ReportTypeEnum } from '../Helper/MPGAReportEnum'; -import { AllData, mockData, months } from '../mockData'; -import { ExportCsvButton } from './ExportCsvButton'; - -jest.mock('../CustomExport/CustomExport', () => ({ - exportToCsv: jest.fn(), -})); - -const TestComponent: React.FC<{ data?: AllData }> = ({ data = mockData }) => ( - - - -); - -describe('ExportCsvButton', () => { - beforeEach(() => { - (exportToCsv as jest.Mock).mockClear(); - }); - - it('renders an Export CSV button', () => { - const { getByRole } = render(); - - expect(getByRole('button', { name: 'Export CSV' })).toBeInTheDocument(); - }); - - it('opens a menu with Income and Expenses options when clicked', async () => { - const { getByRole, findByRole } = render(); - - userEvent.click(getByRole('button', { name: 'Export CSV' })); - - expect( - await findByRole('menuitem', { name: 'Income' }), - ).toBeInTheDocument(); - expect( - await findByRole('menuitem', { name: 'Expenses' }), - ).toBeInTheDocument(); - }); - - it('exports the income CSV when Income is selected', async () => { - const { getByRole, findByRole } = render(); - - userEvent.click(getByRole('button', { name: 'Export CSV' })); - userEvent.click(await findByRole('menuitem', { name: 'Income' })); - - expect(exportToCsv).toHaveBeenCalledWith( - mockData.income, - ReportTypeEnum.Income, - months, - 'en-US', - ); - }); - - it('exports the expenses CSV when Expenses is selected', async () => { - const { getByRole, findByRole } = render(); - - userEvent.click(getByRole('button', { name: 'Export CSV' })); - userEvent.click(await findByRole('menuitem', { name: 'Expenses' })); - - expect(exportToCsv).toHaveBeenCalledWith( - mockData.expenses, - ReportTypeEnum.Expenses, - months, - 'en-US', - ); - }); - - it('disables an export option when its dataset is empty', async () => { - const { getByRole, findByRole } = render( - , - ); - - userEvent.click(getByRole('button', { name: 'Export CSV' })); - - expect( - await findByRole('menuitem', { name: 'Income' }), - ).not.toHaveAttribute('aria-disabled'); - - const expenses = await findByRole('menuitem', { name: 'Expenses' }); - expect(expenses).toHaveAttribute('aria-disabled', 'true'); - expect(exportToCsv).not.toHaveBeenCalled(); - }); - - it('disables both export options when all datasets are empty', async () => { - const { getByRole, findByRole } = render( - , - ); - - userEvent.click(getByRole('button', { name: 'Export CSV' })); - - expect(await findByRole('menuitem', { name: 'Income' })).toHaveAttribute( - 'aria-disabled', - 'true', - ); - expect(await findByRole('menuitem', { name: 'Expenses' })).toHaveAttribute( - 'aria-disabled', - 'true', - ); - expect(exportToCsv).not.toHaveBeenCalled(); - }); - - it('closes the menu after an export is selected', async () => { - const { getByRole, findByRole, queryByRole } = render(); - - userEvent.click(getByRole('button', { name: 'Export CSV' })); - userEvent.click(await findByRole('menuitem', { name: 'Income' })); - - await waitFor(() => - expect( - queryByRole('menuitem', { name: 'Income' }), - ).not.toBeInTheDocument(), - ); - }); -}); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.tsx b/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.tsx deleted file mode 100644 index c6fbb871ae..0000000000 --- a/src/components/Reports/MPGAIncomeExpensesReport/ExportCsvButton/ExportCsvButton.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { useState } from 'react'; -import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; -import FileDownloadIcon from '@mui/icons-material/FileDownload'; -import { Menu, MenuItem, SvgIcon } from '@mui/material'; -import { useTranslation } from 'react-i18next'; -import { useLocale } from 'src/hooks/useLocale'; -import { StyledPrintButton } from '../../styledComponents'; -import { exportToCsv } from '../CustomExport/CustomExport'; -import { ReportTypeEnum } from '../Helper/MPGAReportEnum'; -import { AllData } from '../mockData'; - -interface ExportCsvButtonProps { - data: AllData; - months: string[]; -} - -export const ExportCsvButton: React.FC = ({ - data, - months, -}) => { - const { t } = useTranslation(); - const locale = useLocale(); - const [anchorEl, setAnchorEl] = useState(null); - const open = Boolean(anchorEl); - - const handleOpen = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); - }; - - const handleExport = (type: ReportTypeEnum) => { - let rows: AllData['income']; - switch (type) { - case ReportTypeEnum.Income: - rows = data.income; - break; - case ReportTypeEnum.Expenses: - rows = data.expenses; - break; - default: - // Exhaustiveness check: adding a ReportTypeEnum member without - // handling it here becomes a compile-time error. - return ((_exhaustive: never) => _exhaustive)(type); - } - exportToCsv(rows, type, months, locale); - handleClose(); - }; - - return ( - <> - - - - } - endIcon={} - onClick={handleOpen} - > - {t('Export CSV')} - - - handleExport(ReportTypeEnum.Income)} - > - {t('Income')} - - handleExport(ReportTypeEnum.Expenses)} - > - {t('Expenses')} - - - - ); -}; diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx index 2de5c8a452..4c3c78bf74 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.test.tsx @@ -64,7 +64,6 @@ describe('MPGAIncomeExpensesReport', () => { const { getByRole, findByText } = render(); expect(getByRole('heading', { name: title })).toBeInTheDocument(); expect(getByRole('button', { name: 'Print' })).toBeInTheDocument(); - expect(getByRole('button', { name: 'Export CSV' })).toBeInTheDocument(); expect(await findByText('12345')).toBeInTheDocument(); expect(await findByText('Test Account')).toBeInTheDocument(); diff --git a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx index 8e7a85d4a6..199d1d4194 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/MPGAIncomeExpensesReport.tsx @@ -30,7 +30,6 @@ import { } from '../styledComponents'; import { PrintOnlyReport } from './DisplayModes/PrintOnlyReport'; import { ScreenOnlyReport } from './DisplayModes/ScreenOnlyReport'; -import { ExportCsvButton } from './ExportCsvButton/ExportCsvButton'; import { FundTypes, Funds } from './Helper/MPGAReportEnum'; import { convertMonths } from './Helper/convertMonths'; import { useMpgaTransactionsQuery } from './MPGATransactions.generated'; @@ -140,16 +139,11 @@ export const MPGAIncomeExpensesReport: React.FC< {t('Income & Expenses Analysis: Last 12 Months')} - button': { ml: 0 } }} - > - + - + } onClick={handlePrint} diff --git a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx index 71e440897f..91b95786b7 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/Tables/TableCard.tsx @@ -37,8 +37,14 @@ export const descriptionWidth = 175; export const monthWidth = 65; export const summaryWidth = 98.5; -const createToolbar = (months: string[]) => { - const Toolbar = () => ; +const createToolbar = ( + data: DataFields[], + type: ReportTypeEnum, + months: string[], +) => { + const Toolbar = () => ( + + ); Toolbar.displayName = 'MPGATableCustomToolbar'; return Toolbar; }; @@ -156,7 +162,7 @@ export const TableCard: React.FC = ({ pagination disableColumnMenu slots={{ - toolbar: createToolbar(months), + toolbar: createToolbar(data, type, months), }} showToolbar /> diff --git a/src/components/Reports/MPGAIncomeExpensesReport/styledComponents.tsx b/src/components/Reports/MPGAIncomeExpensesReport/styledComponents.tsx index afd95954a2..63dc844101 100644 --- a/src/components/Reports/MPGAIncomeExpensesReport/styledComponents.tsx +++ b/src/components/Reports/MPGAIncomeExpensesReport/styledComponents.tsx @@ -7,7 +7,6 @@ import { DataFields } from './mockData'; export const StyledHeaderBox = styled(Box)({ display: 'flex', alignItems: 'center', - flexWrap: 'wrap', gap: theme.spacing(2), justifyContent: 'space-between', }); diff --git a/src/components/Reports/StaffExpenseReport/BalanceCard/BalanceCard.test.tsx b/src/components/Reports/StaffExpenseReport/BalanceCard/BalanceCard.test.tsx index 0c19065ecb..75e7813b61 100644 --- a/src/components/Reports/StaffExpenseReport/BalanceCard/BalanceCard.test.tsx +++ b/src/components/Reports/StaffExpenseReport/BalanceCard/BalanceCard.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Wallet } from '@mui/icons-material'; -import { render, within } from '@testing-library/react'; +import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { BalanceCard } from './BalanceCard'; @@ -12,7 +12,7 @@ const defaultProps = { startBalance: 1000, endBalance: 1500, transfersIn: 500, - transfersOut: -100, + transfersOut: 100, isSelected: false, onClick: jest.fn(), }; @@ -51,61 +51,36 @@ describe('BalanceCard', () => { it('should format positive balances correctly', () => { const { getByText } = render( - , + , ); expect(getByText('Starting Balance: $1,234.56')).toBeInTheDocument(); }); it('should format negative balances correctly', () => { const { getByText } = render( - , + , ); expect(getByText('Starting Balance: -$500.25')).toBeInTheDocument(); }); it('should format zero values correctly', () => { const { getByText } = render( - , + , ); - const income = getByText('Income:'); - expect(within(income).getByText('$0.00')).toBeInTheDocument(); + expect(getByText('+ Transfers in: $0')).toBeInTheDocument(); }); it('should handle large numbers correctly', () => { const { getByText } = render( - , + , ); expect(getByText('= Ending Balance: $1,000,000.99')).toBeInTheDocument(); }); - it('should handle negative end balance correctly', () => { + it('should use Math.abs for transfers out display', () => { const { getByText } = render( - , + , ); - expect(getByText('($1,234.56)')).toBeInTheDocument(); - }); - - it('should display transfers out with its sign preserved', () => { - const { getByText } = render( - , - ); - const expenses = getByText('Expenses:'); - expect(within(expenses).getByText('-$250.50')).toBeInTheDocument(); + expect(getByText('- Transfers out: $250.50')).toBeInTheDocument(); }); }); diff --git a/src/components/Reports/StaffExpenseReport/BalanceCard/BalanceCard.tsx b/src/components/Reports/StaffExpenseReport/BalanceCard/BalanceCard.tsx index 559af465a7..a8799d60b9 100644 --- a/src/components/Reports/StaffExpenseReport/BalanceCard/BalanceCard.tsx +++ b/src/components/Reports/StaffExpenseReport/BalanceCard/BalanceCard.tsx @@ -4,7 +4,6 @@ import { Box, Card, CardActionArea, Typography, styled } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useLocale } from 'src/hooks/useLocale'; import { currencyFormat } from 'src/lib/intlFormat'; -import theme from 'src/theme'; import { StyledIconBox } from '../styledComponents/StyledIconBox'; const StyledCardActionArea = styled(CardActionArea, { @@ -49,12 +48,10 @@ interface BalanceCardProps { isSelected?: boolean; } -const StyledHeaderBox = styled(Box, { - shouldForwardProp: (prop) => prop !== 'isSelected', -})<{ isSelected: boolean }>(({ theme, isSelected }) => ({ +const StyledHeaderBox = styled(Box)(({ theme }) => ({ display: 'flex', flex: 1, - flexDirection: isSelected ? 'row' : 'column', + flexDirection: 'row', alignItems: 'start', gap: theme.spacing(1), })); @@ -80,79 +77,32 @@ export const BalanceCard: React.FC = ({ const { t } = useTranslation(); const locale = useLocale(); - const formatBalance = (amount: number) => - currencyFormat(amount, 'USD', locale, { - showTrailingZeros: true, - }); - - const isNegative = endBalance < 0; - return ( - + - - {title} - + {title} - {isSelected ? ( - - - {t('Starting Balance: ')} - {formatBalance(startBalance)} - - - {t('Income: ')} - 0 ? theme.palette.success.main : 'inherit', - }} - > - {formatBalance(transfersIn)} - - - - {t('Expenses: ')} - - {formatBalance(transfersOut)} - - - - - {t('= Ending Balance: ')} - {formatBalance(endBalance)} - - - - ) : ( - - {isNegative ? '(' : ''} - {currencyFormat(Math.abs(endBalance), 'USD', locale, { - showTrailingZeros: true, - })} - {isNegative ? ')' : ''} + + + {t('Starting Balance: ')} + {currencyFormat(startBalance, 'USD', locale)} + + + {t('+ Transfers in: ')} + {currencyFormat(transfersIn, 'USD', locale)} + + + {t('- Transfers out: ')} + {currencyFormat(Math.abs(transfersOut), 'USD', locale)} + + + {t('= Ending Balance: ')} + {currencyFormat(endBalance, 'USD', locale)} - )} + = ({ color="primary.main" fontWeight={600} textAlign="center" - sx={{ whiteSpace: 'nowrap' }} > {t('Currently Viewing')} diff --git a/src/components/Reports/StaffExpenseReport/BalanceCard/PrintHeader.tsx b/src/components/Reports/StaffExpenseReport/BalanceCard/PrintHeader.tsx index 34ff1e1dfb..c72c74d229 100644 --- a/src/components/Reports/StaffExpenseReport/BalanceCard/PrintHeader.tsx +++ b/src/components/Reports/StaffExpenseReport/BalanceCard/PrintHeader.tsx @@ -77,17 +77,22 @@ export const PrintHeader: React.FC = ({ - {t('Income: {{transfersIn}}', { + {t('+ Transfers in: {{transfersIn}}', { transfersIn: currencyFormat(transfersIn, 'USD', locale, { showTrailingZeros: true, }), })} - {t('Expenses: {{transfersOut}}', { - transfersOut: currencyFormat(transfersOut, 'USD', locale, { - showTrailingZeros: true, - }), + {t('- Transfers out: {{transfersOut}}', { + transfersOut: currencyFormat( + Math.abs(transfersOut), + 'USD', + locale, + { + showTrailingZeros: true, + }, + ), })} diff --git a/src/components/Reports/StaffExpenseReport/BalanceCardList/BalanceCardList.test.tsx b/src/components/Reports/StaffExpenseReport/BalanceCardList/BalanceCardList.test.tsx index 037e18b294..d5a215debd 100644 --- a/src/components/Reports/StaffExpenseReport/BalanceCardList/BalanceCardList.test.tsx +++ b/src/components/Reports/StaffExpenseReport/BalanceCardList/BalanceCardList.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { ThemeProvider } from '@mui/material/styles'; -import { render, within } from '@testing-library/react'; +import { render } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { Fund } from 'src/graphql/types.generated'; import theme from 'src/theme'; @@ -36,12 +36,11 @@ const mockFunds: Fund[] = [ const defaultProps = { funds: mockFunds, - selectedFundType: 'Primary', - // Expenses are summed from negative-amount transactions, so `out` is negative. + selectedFundType: null, transferTotals: { - Primary: { in: 500, out: -100 }, - Savings: { in: 300, out: -50 }, - ConferenceSavings: { in: 0, out: -200 }, + Primary: { in: 500, out: 100 }, + Savings: { in: 300, out: 50 }, + ConferenceSavings: { in: 0, out: 200 }, }, onCardClick: onCardClick, loading: false, @@ -73,7 +72,7 @@ describe('BalanceCardList', () => { const { getAllByRole } = render(); userEvent.click(getAllByRole('button', { name: 'View Account' })[0]); - expect(onCardClick).toHaveBeenCalledWith('Savings'); + expect(onCardClick).toHaveBeenCalledWith('Primary'); }); it('displays selected state on correct card', () => { @@ -87,17 +86,20 @@ describe('BalanceCardList', () => { expect(getAllByRole('button', { name: 'View Account' })).toHaveLength(2); }); - it('displays values for the selected card', async () => { - const { getByText, findByText } = render( - , - ); - - const income = await findByText('Income:'); - expect(within(income).getByText('$500.00')).toBeInTheDocument(); - const expenses = getByText('Expenses:'); - expect(within(expenses).getByText('-$100.00')).toBeInTheDocument(); - expect(getByText('Starting Balance: $1,000.00')).toBeInTheDocument(); - expect(getByText('= Ending Balance: $1,400.00')).toBeInTheDocument(); + it('displays values for each card', () => { + const { getByText } = render(); + expect(getByText('+ Transfers in: $500')).toBeInTheDocument(); + expect(getByText('- Transfers out: $100')).toBeInTheDocument(); + expect(getByText('+ Transfers in: $300')).toBeInTheDocument(); + expect(getByText('- Transfers out: $50')).toBeInTheDocument(); + expect(getByText('+ Transfers in: $0')).toBeInTheDocument(); + expect(getByText('- Transfers out: $200')).toBeInTheDocument(); + expect(getByText('Starting Balance: $1,000')).toBeInTheDocument(); + expect(getByText('Starting Balance: $2,000')).toBeInTheDocument(); + expect(getByText('Starting Balance: $500')).toBeInTheDocument(); + expect(getByText('= Ending Balance: $1,400')).toBeInTheDocument(); + expect(getByText('= Ending Balance: $2,250')).toBeInTheDocument(); + expect(getByText('= Ending Balance: $300')).toBeInTheDocument(); }); it('renders empty when no funds provided', () => { diff --git a/src/components/Reports/StaffExpenseReport/BalanceCardList/BalanceCardList.tsx b/src/components/Reports/StaffExpenseReport/BalanceCardList/BalanceCardList.tsx index bfb8a68cb4..31dbe9396f 100644 --- a/src/components/Reports/StaffExpenseReport/BalanceCardList/BalanceCardList.tsx +++ b/src/components/Reports/StaffExpenseReport/BalanceCardList/BalanceCardList.tsx @@ -9,14 +9,11 @@ import { getIconForFundType, } from '../Helpers/fundTypeHelpers'; -const StyledCardsBox = styled(Box, { - shouldForwardProp: (prop) => prop !== 'isSelected', -})<{ isSelected: boolean }>(({ theme, isSelected }) => ({ - flex: isSelected ? 2 : 1, - minWidth: isSelected ? 250 : 200, +const StyledCardsBox = styled(Box)(({ theme }) => ({ + flex: 1, + minWidth: 240, display: 'flex', gap: theme.spacing(4), - transition: 'flex 0.3s ease-in-out, min-width 0.3s ease-in-out', })); export interface BalanceCardListProps { @@ -38,7 +35,7 @@ export const BalanceCardList: React.FC = ({ if (loading) { return ( - + @@ -49,10 +46,7 @@ export const BalanceCardList: React.FC = ({ return ( <> {funds.map((fund) => ( - + { expect(getByRole('cell', { name: '$300' })).toBeInTheDocument(); }); - it('renders four column headers including Category', () => { - const { getAllByRole, getByRole } = render( - , - ); - - expect(getAllByRole('columnheader')).toHaveLength(4); - expect(getByRole('columnheader', { name: 'Date' })).toBeInTheDocument(); - expect( - getByRole('columnheader', { name: 'Description' }), - ).toBeInTheDocument(); - expect(getByRole('columnheader', { name: 'Category' })).toBeInTheDocument(); - expect(getByRole('columnheader', { name: 'Amount' })).toBeInTheDocument(); - }); - - it('displays the transaction description in its own column', () => { - const { getByRole } = render(); - - expect(getByRole('cell', { name: 'Salary Payment 1' })).toBeInTheDocument(); - expect(getByRole('cell', { name: 'Salary Payment 2' })).toBeInTheDocument(); - }); - - it('places description and Category in separate, adjacent columns', () => { - const { getAllByRole } = render(); - - // rows[0] is the header; rows[1] is the first sorted transaction. - const firstRowCells = within(getAllByRole('row')[1]).getAllByRole('cell'); - // Columns: Date | Description | Category | Amount - expect(firstRowCells[1]).toHaveTextContent('Salary Payment 1'); - expect(firstRowCells[2]).toHaveTextContent('Salary - Salary Other'); - }); - it('displays total amount', () => { const { getByRole } = render(); diff --git a/src/components/Reports/StaffExpenseReport/CategoryBreakdownDialog/CategoryBreakdownDialog.tsx b/src/components/Reports/StaffExpenseReport/CategoryBreakdownDialog/CategoryBreakdownDialog.tsx index e46afd709b..6bf2408422 100644 --- a/src/components/Reports/StaffExpenseReport/CategoryBreakdownDialog/CategoryBreakdownDialog.tsx +++ b/src/components/Reports/StaffExpenseReport/CategoryBreakdownDialog/CategoryBreakdownDialog.tsx @@ -75,12 +75,9 @@ export const CategoryBreakdownDialog: React.FC< fontWeight: 'bold', }} > - {t('Date')} - {t('Description')} - {t('Category')} - - {t('Amount')} - + {t('Date')} + {t('Description')} + {t('Amount')} @@ -92,7 +89,6 @@ export const CategoryBreakdownDialog: React.FC< locale, )} - {transaction.description} {transaction.displayCategory} {currencyFormat(transaction.amount, 'USD', locale)} @@ -108,7 +104,7 @@ export const CategoryBreakdownDialog: React.FC< }} > - + string; -} - -const fundTypeConfig: Record = { - Primary: { - icon: Wallet, - getColor: (theme) => theme.palette.chartOrange.main, - }, - Savings: { - icon: Savings, - getColor: (theme) => theme.palette.chartBlueDark.main, - }, - 'Staff Conference Savings': { - icon: Diversity1, - getColor: (theme) => theme.palette.chartBlue.main, - }, - 'Return Travel': { - icon: Flight, - getColor: (theme) => theme.palette.chipYellowDark.main, - }, - 'Re-Entry': { - icon: Home, - getColor: (theme) => theme.palette.chartGray.main, - }, -}; - -const defaultFundTypeConfig: FundTypeConfig = { - icon: Groups, - getColor: (theme) => theme.palette.chartBlue.main, +export const getIconForFundType = (fundType: string): SvgIconComponent => { + if (fundType === 'Primary') { + return Wallet; + } + if (fundType === 'Savings') { + return Savings; + } + if (fundType === 'Staff Conference Savings') { + return Diversity1; + } + return Groups; }; -export const getIconForFundType = (fundType: string): SvgIconComponent => - (fundTypeConfig[fundType] ?? defaultFundTypeConfig).icon; - export const getIconColorForFundType = ( fundType: string, theme: Theme, -): string => - (fundTypeConfig[fundType] ?? defaultFundTypeConfig).getColor(theme); +): string => { + if (fundType === 'Primary') { + return theme.palette.chartOrange.main; + } + if (fundType === 'Savings') { + return theme.palette.chartBlueDark.main; + } + if (fundType === 'Staff Conference Savings') { + return theme.palette.chartBlueLight.main; + } + return theme.palette.chartBlue.main; +}; diff --git a/src/components/Reports/StaffExpenseReport/StaffExpenseReport.test.tsx b/src/components/Reports/StaffExpenseReport/StaffExpenseReport.test.tsx index ab360fe122..2aae8d8d41 100644 --- a/src/components/Reports/StaffExpenseReport/StaffExpenseReport.test.tsx +++ b/src/components/Reports/StaffExpenseReport/StaffExpenseReport.test.tsx @@ -225,7 +225,6 @@ describe('StaffExpenseReport', () => { expect(getByRole('heading', { name: 'Report title' })).toBeInTheDocument(); expect(await findByText('Test Account')).toBeInTheDocument(); expect(await findByText('1000000001')).toBeInTheDocument(); - expect(await findByText('$4,000.00')).toBeInTheDocument(); }); it('initializes with month from query', () => { diff --git a/src/components/Reports/StaffExpenseReport/StaffExpenseReport.tsx b/src/components/Reports/StaffExpenseReport/StaffExpenseReport.tsx index a50a193719..bfdfec386d 100644 --- a/src/components/Reports/StaffExpenseReport/StaffExpenseReport.tsx +++ b/src/components/Reports/StaffExpenseReport/StaffExpenseReport.tsx @@ -98,13 +98,7 @@ export const StaffExpenseReport: React.FC = ({ const { data, loading } = useReportsStaffExpensesQuery({ variables: { - fundTypes: [ - 'Primary', - 'Savings', - 'Staff Conference Savings', - 'Return Travel', - 'Re-Entry', - ], + fundTypes: ['Primary', 'Savings', 'Staff Conference Savings'], ...getStaffExpenseMonthRange(filters, time), }, }); @@ -246,11 +240,6 @@ export const StaffExpenseReport: React.FC = ({ return null; }, [filters, locale, t]); - const overallBalance = useMemo( - () => allFunds.reduce((sum, fund) => sum + (fund.endBalance ?? 0), 0), - [allFunds], - ); - return ( @@ -300,13 +289,9 @@ export const StaffExpenseReport: React.FC = ({ ) : null} {loading ? ( - + ) : ( - + )} { expect(getByRole('gridcell', { name: '-$100' })).toBeInTheDocument(); }); - it('relabels a Donation category as "Total Donations" regardless of locale', async () => { - const { findByRole } = render( - , - ); - - expect( - await findByRole('gridcell', { name: 'Total Donations' }), - ).toBeInTheDocument(); - }); - it('renders loading spinner when loading prop is true', async () => { const { findByTestId } = render( , diff --git a/src/components/Reports/StaffExpenseReport/Tables/StaffReportTable.tsx b/src/components/Reports/StaffExpenseReport/Tables/StaffReportTable.tsx index 176ed23e6a..db6494bf8f 100644 --- a/src/components/Reports/StaffExpenseReport/Tables/StaffReportTable.tsx +++ b/src/components/Reports/StaffExpenseReport/Tables/StaffReportTable.tsx @@ -9,10 +9,8 @@ import { } from '@mui/material'; import { styled, useTheme } from '@mui/material/styles'; import { DataGrid, GridColDef, GridSortModel } from '@mui/x-data-grid'; -import { TFunction } from 'i18next'; import { DateTime } from 'luxon'; import { useTranslation } from 'react-i18next'; -import { StaffExpenseCategoryEnum } from 'src/graphql/types.generated'; import { useLocale } from 'src/hooks/useLocale'; import { currencyFormat, dateFormat } from 'src/lib/intlFormat'; import { CategoryBreakdownDialog } from '../CategoryBreakdownDialog/CategoryBreakdownDialog'; @@ -23,31 +21,24 @@ type RenderCell = GridColDef['renderCell']; export interface StaffReportTableProps { transactions: (Transaction | GroupedTransaction)[]; - tableType: ReportType.Income | ReportType.Expense; + tableType: ReportType; transferTotal: number; emptyPlaceholder: React.ReactElement; loading?: boolean; } -const StyledGrid = styled(DataGrid, { - shouldForwardProp: (prop) => prop !== 'tableType', -})<{ tableType: ReportType.Income | ReportType.Expense }>( - ({ theme, tableType }) => ({ - '.MuiDataGrid-row:nth-of-type(2n + 1):not(:hover)': { - backgroundColor: - tableType === ReportType.Expense - ? theme.palette.chipRedLight.main - : theme.palette.mpdxGrayLight.main, - }, - '.MuiDataGrid-cell': { - overflow: 'hidden', - whiteSpace: 'nowrap', - textOverflow: 'ellipsis', - display: 'flex', - alignItems: 'center', - }, - }), -); +const StyledGrid = styled(DataGrid)(({ theme }) => ({ + '.MuiDataGrid-row:nth-of-type(2n + 1):not(:hover)': { + backgroundColor: theme.palette.mpdxGrayLight.main, + }, + '.MuiDataGrid-cell': { + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', + display: 'flex', + alignItems: 'center', + }, +})); const LoadingBox = styled(Box)(({ theme }) => ({ backgroundColor: theme.palette.mpdxGrayLight.main, @@ -73,28 +64,15 @@ export interface StaffReportRow { groupedTransaction?: GroupedTransaction; } -const descriptionName = ( - transaction: Transaction | GroupedTransaction, - t: TFunction, -): string => { - // Compare against the locale-invariant enum, not the localized - // displayCategory, so the relabel works in every locale. - if (transaction.category === StaffExpenseCategoryEnum.Donation) { - return t('Total Donations'); - } - return transaction.displayCategory; -}; - export const createStaffReportRow = ( transaction: Transaction | GroupedTransaction, index: number, - t: TFunction, ): StaffReportRow => { const isGrouped = 'groupedTransactions' in transaction; return { id: index.toString(), date: DateTime.fromISO(transaction.transactedAt), - description: descriptionName(transaction, t), + description: transaction.displayCategory, amount: transaction.amount, isGrouped, groupedTransaction: isGrouped ? transaction : undefined, @@ -148,10 +126,8 @@ export const StaffReportTable: React.FC = ({ }; const staffReportRows = useMemo(() => { - return transactions.map((data, index) => - createStaffReportRow(data, index, t), - ); - }, [transactions, t]); + return transactions.map((data, index) => createStaffReportRow(data, index)); + }, [transactions]); const date: RenderCell = ({ row }) => { return dateFormat(row.date, locale); @@ -262,7 +238,6 @@ export const StaffReportTable: React.FC = ({ )} row.id} diff --git a/src/components/Shared/Autosave/useAutosave.test.tsx b/src/components/Shared/Autosave/useAutosave.test.tsx index ba6d93079d..d76628cb89 100644 --- a/src/components/Shared/Autosave/useAutosave.test.tsx +++ b/src/components/Shared/Autosave/useAutosave.test.tsx @@ -55,37 +55,6 @@ const SelectTestComponent: React.FC = () => { ); }; -const SaveOnChangeTextComponent: React.FC = () => { - const schema = yup.object({ - field: amount('Field', i18n.t, { required: true }), - }); - - const props = useAutoSave({ - value: 100, - saveValue, - fieldName: 'field', - schema, - saveOnChange: true, - }); - - return ; -}; - -const TouchedTestComponent: React.FC = () => { - const schema = yup.object({ - field: amount('Field', i18n.t, { required: true }), - }); - - const props = useAutoSave({ - value: null, - saveValue, - fieldName: 'field', - schema, - }); - - return ; -}; - const TransformTestComponent: React.FC = () => { const schema = yup.object({ field: yup.string().transform((val) => val.replace(/\D/g, '')), @@ -142,15 +111,16 @@ describe('AutosaveTextField', () => { expect(saveValue).not.toHaveBeenCalled(); }); - it('shows validation error after blur', () => { + it('shows validation error', () => { const { getByRole } = render(); const input = getByRole('textbox', { name: 'Field' }); userEvent.clear(input); userEvent.type(input, '-100'); - userEvent.tab(); - expect(input).toHaveAccessibleDescription('Field must be positive'); + + input.blur(); + expect(saveValue).not.toHaveBeenCalled(); }); @@ -162,14 +132,12 @@ describe('AutosaveTextField', () => { expect(input).toHaveAccessibleDescription(''); }); - it('shows validation error for invalid type after blur', () => { + it('shows validation error for invalid type', () => { const { getByRole } = render(); const input = getByRole('textbox', { name: 'Field' }); userEvent.clear(input); userEvent.type(input, 'abc'); - userEvent.tab(); - expect(input).toHaveAccessibleDescription('Field must be a number'); }); @@ -182,42 +150,6 @@ describe('AutosaveTextField', () => { expect(input).toHaveValue('123'); }); - describe('error visibility', () => { - it('hides the validation error until the field is blurred', () => { - const { getByRole } = render(); - - const input = getByRole('textbox', { name: 'Field' }); - expect(input).toHaveAccessibleDescription(''); - - input.focus(); - userEvent.tab(); - - expect(input).toHaveAccessibleDescription('Field is required'); - }); - - it('does not show the validation error while the user types', () => { - const { getByRole } = render(); - - const input = getByRole('textbox', { name: 'Field' }); - userEvent.type(input, '-100'); - expect(input).toHaveAccessibleDescription(''); - - userEvent.tab(); - - expect(input).toHaveAccessibleDescription('Field must be positive'); - }); - - it('shows the validation error while typing when saveOnChange is true', () => { - const { getByRole } = render(); - - const input = getByRole('textbox', { name: 'Field' }); - userEvent.clear(input); - userEvent.type(input, '-100'); - - expect(input).toHaveAccessibleDescription('Field must be positive'); - }); - }); - describe('select input', () => { it('saves on change', () => { const { getByRole } = render(); diff --git a/src/components/Shared/Autosave/useAutosave.ts b/src/components/Shared/Autosave/useAutosave.ts index c2b660bfca..ff45ff5ec1 100644 --- a/src/components/Shared/Autosave/useAutosave.ts +++ b/src/components/Shared/Autosave/useAutosave.ts @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo } from 'react'; import { TextFieldProps } from '@mui/material'; import { prepareDataForValidation } from 'formik'; import * as yup from 'yup'; @@ -25,7 +25,6 @@ export const useAutoSave = ({ const [internalValue, setInternalValue] = useSyncedState( value?.toString() ?? '', ); - const [touched, setTouched] = useState(false); const { markValid, markInvalid } = useOptionalAutosaveForm() ?? {}; const parseValue = useCallback( @@ -81,8 +80,6 @@ export const useAutoSave = ({ }; }, [fieldName, errorMessage, markValid, markInvalid]); - const showError = !disabled && errorMessage !== null && touched; - return { value: internalValue, onChange: (event: React.ChangeEvent) => { @@ -90,7 +87,6 @@ export const useAutoSave = ({ setInternalValue(newValue); if (saveOnChange) { - setTouched(true); const { parsedValue, errorMessage } = parseValue(newValue); if (errorMessage === null && parsedValue !== value) { saveValue(parsedValue); @@ -98,12 +94,13 @@ export const useAutoSave = ({ } }, onBlur: () => { - setTouched(true); if (!saveOnChange && errorMessage === null && parsedValue !== value) { saveValue(parsedValue); } }, disabled, - ...(showError ? { error: true, helperText: errorMessage } : {}), + ...(!disabled && errorMessage !== null + ? { error: true, helperText: errorMessage } + : {}), } satisfies Partial; }; diff --git a/src/components/Shared/MultiPageLayout/MultiPageHeader.tsx b/src/components/Shared/MultiPageLayout/MultiPageHeader.tsx index 718f6f13e3..a5a868efe6 100644 --- a/src/components/Shared/MultiPageLayout/MultiPageHeader.tsx +++ b/src/components/Shared/MultiPageLayout/MultiPageHeader.tsx @@ -26,7 +26,7 @@ interface MultiPageHeaderProps { rightExtra?: ReactNode; } -export const StickyHeader = styled(Box)(() => ({ +const StickyHeader = styled(Box)(() => ({ position: 'sticky', top: 0, borderBottom: '1px solid', @@ -39,7 +39,7 @@ export const StickyHeader = styled(Box)(() => ({ }, })); -export const NavListButton = styled(IconButton, { +const NavListButton = styled(IconButton, { shouldForwardProp: (prop) => prop !== 'panelOpen', })(({ panelOpen }: { panelOpen: boolean }) => ({ display: 'inline-block', @@ -52,7 +52,7 @@ export const NavListButton = styled(IconButton, { padding: '11px', })); -export const NavMenuIcon = styled(MenuIcon)(() => ({ +const NavMenuIcon = styled(MenuIcon)(() => ({ width: 24, height: 24, color: theme.palette.primary.dark, diff --git a/src/hooks/useHrToolsNavItems.test.tsx b/src/hooks/useHrToolsNavItems.test.tsx index 6e68b5bee6..fb99458ea0 100644 --- a/src/hooks/useHrToolsNavItems.test.tsx +++ b/src/hooks/useHrToolsNavItems.test.tsx @@ -57,7 +57,6 @@ describe('useHrToolsNavItems', () => { 'additionalSalaryRequest', 'pdsGoalCalculator', 'partnerReminders', - 'mpdSupervisorReport', ]); }); diff --git a/src/hooks/useHrToolsNavItems.ts b/src/hooks/useHrToolsNavItems.ts index 7977ffe6b3..b81ede2dc5 100644 --- a/src/hooks/useHrToolsNavItems.ts +++ b/src/hooks/useHrToolsNavItems.ts @@ -76,11 +76,6 @@ export function useHrToolsNavItems(): { title: t('Ministry Partner Reminders'), hideItem: hasNoStaffAccount, }, - { - id: 'mpdSupervisorReport', - title: t('MPD Supervisor Report'), - hideItem: hasNoStaffAccount, - }, ].filter((item) => developerBypass || !item.hideItem); }, [ t, diff --git a/src/lib/apollo/relayStylePaginationWithNodes.test.tsx b/src/lib/apollo/relayStylePaginationWithNodes.test.tsx deleted file mode 100644 index f1e3f21926..0000000000 --- a/src/lib/apollo/relayStylePaginationWithNodes.test.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import { InMemoryCache, gql } from '@apollo/client'; -import { relayStylePaginationWithNodes } from './relayStylePaginationWithNodes'; - -const QUERY = gql` - query Items { - items { - nodes { - id - name - } - edges { - node { - id - name - } - cursor - } - pageInfo { - hasNextPage - endCursor - } - } - } -`; - -const makeCache = () => - new InMemoryCache({ - typePolicies: { - Query: { - fields: { - items: relayStylePaginationWithNodes(), - }, - }, - }, - }); - -const writeItems = (cache: InMemoryCache) => - cache.writeQuery({ - query: QUERY, - data: { - items: { - __typename: 'ItemConnection', - nodes: [ - { __typename: 'Item', id: '1', name: 'one' }, - { __typename: 'Item', id: '2', name: 'two' }, - ], - edges: [ - { - __typename: 'ItemEdge', - cursor: 'c1', - node: { __typename: 'Item', id: '1', name: 'one' }, - }, - { - __typename: 'ItemEdge', - cursor: 'c2', - node: { __typename: 'Item', id: '2', name: 'two' }, - }, - ], - pageInfo: { - __typename: 'PageInfo', - hasNextPage: false, - endCursor: 'c2', - }, - }, - }, - }); - -describe('relayStylePaginationWithNodes', () => { - it('returns all nodes and edges before eviction', () => { - const cache = makeCache(); - writeItems(cache); - - const result = cache.readQuery<{ - items: { nodes: { id: string }[]; edges: { node: { id: string } }[] }; - }>({ query: QUERY }); - - expect(result?.items.nodes.map((node) => node.id)).toEqual(['1', '2']); - expect(result?.items.edges.map((edge) => edge.node.id)).toEqual(['1', '2']); - }); - - it('filters evicted items out of both nodes and edges', () => { - const cache = makeCache(); - writeItems(cache); - - cache.evict({ id: 'Item:1' }); - cache.gc(); - - const result = cache.readQuery<{ - items: { nodes: { id: string }[]; edges: { node: { id: string } }[] }; - }>({ query: QUERY }); - - // The remaining item is read cleanly with no dangling reference error. - expect(result?.items.nodes.map((node) => node.id)).toEqual(['2']); - expect(result?.items.edges.map((edge) => edge.node.id)).toEqual(['2']); - }); -}); diff --git a/src/lib/apollo/relayStylePaginationWithNodes.tsx b/src/lib/apollo/relayStylePaginationWithNodes.tsx index 892981b6ec..0b0a390c03 100644 --- a/src/lib/apollo/relayStylePaginationWithNodes.tsx +++ b/src/lib/apollo/relayStylePaginationWithNodes.tsx @@ -56,19 +56,11 @@ export function relayStylePaginationWithNodes( const { startCursor, endCursor } = existing.pageInfo || {}; - // `nodes` holds direct references, so filter out any that can no longer - // be read (e.g. evicted from the cache) to avoid dangling references — - // mirroring the `canRead` filtering applied to `edges` above. - const nodes = (existing.nodes ?? []).filter((node) => - canRead(node as Reference), - ); - return { // Some implementations return additional Connection fields, such // as existing.totalCount. These fields are saved by the merge // function, so the read function should also preserve them. ...getExtras(existing), - nodes, edges, pageInfo: { ...existing.pageInfo, @@ -249,7 +241,7 @@ export function relayStylePaginationWithNodes( // eslint-disable-next-line @typescript-eslint/no-explicit-any const getExtras = (obj: Record) => __rest(obj, notExtras); -const notExtras = ['edges', 'nodes', 'pageInfo']; +const notExtras = ['edges', 'pageInfo']; // eslint-disable-next-line @typescript-eslint/no-explicit-any function makeEmptyData(): TExistingRelayWithNodes { diff --git a/src/lib/functions/getLocalizedAge.test.ts b/src/lib/functions/getLocalizedAge.test.ts deleted file mode 100644 index b736e07cdf..0000000000 --- a/src/lib/functions/getLocalizedAge.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { GoalCalculationAge } from 'src/graphql/types.generated'; -import { getLocalizedAge } from './getLocalizedAge'; - -const t = (key: string) => key; - -describe('getLocalizedAge', () => { - it.each([ - [GoalCalculationAge.UnderThirty, 'Under 30'], - [GoalCalculationAge.ThirtyToThirtyFour, '30-34'], - [GoalCalculationAge.ThirtyFiveToThirtyNine, '35-39'], - [GoalCalculationAge.OverForty, 'Over 40'], - ])('maps %s to "%s"', (age, expected) => { - expect(getLocalizedAge(t, age)).toBe(expected); - }); - - it('returns an empty string for null, undefined, or an unrecognized value', () => { - expect(getLocalizedAge(t, null)).toBe(''); - expect(getLocalizedAge(t, undefined)).toBe(''); - expect(getLocalizedAge(t, 'nonsense' as GoalCalculationAge)).toBe(''); - }); -}); diff --git a/src/lib/functions/getLocalizedAge.ts b/src/lib/functions/getLocalizedAge.ts deleted file mode 100644 index dab7360b08..0000000000 --- a/src/lib/functions/getLocalizedAge.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { TFunction } from 'react-i18next'; -import { GoalCalculationAge } from 'src/graphql/types.generated'; - -/** - * Maps a goal calculation age range to a localized, human-readable label. - */ -export const getLocalizedAge = ( - t: TFunction, - age: GoalCalculationAge | null | undefined, -): string => { - switch (age) { - case GoalCalculationAge.UnderThirty: - return t('Under 30'); - case GoalCalculationAge.ThirtyToThirtyFour: - return t('30-34'); - case GoalCalculationAge.ThirtyFiveToThirtyNine: - return t('35-39'); - case GoalCalculationAge.OverForty: - return t('Over 40'); - default: - return ''; - } -}; diff --git a/src/lib/functions/getLocalizedBenefitsPlan.test.ts b/src/lib/functions/getLocalizedBenefitsPlan.test.ts deleted file mode 100644 index c9f62bfabe..0000000000 --- a/src/lib/functions/getLocalizedBenefitsPlan.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { MpdGoalBenefitsConstantPlanEnum } from 'src/graphql/types.generated'; -import { getLocalizedBenefitsPlan } from './getLocalizedBenefitsPlan'; - -const t = (key: string) => key; - -describe('getLocalizedBenefitsPlan', () => { - it.each([ - [MpdGoalBenefitsConstantPlanEnum.Select, 'Select'], - [MpdGoalBenefitsConstantPlanEnum.Plus, 'Plus'], - [MpdGoalBenefitsConstantPlanEnum.Base, 'Base'], - [MpdGoalBenefitsConstantPlanEnum.Minimum, 'Minimum'], - [MpdGoalBenefitsConstantPlanEnum.Exempt, 'Exempt'], - ])('maps %s to "%s"', (plan, expected) => { - expect(getLocalizedBenefitsPlan(t, plan)).toBe(expected); - }); - - it('returns an empty string for null, undefined, or an unrecognized value', () => { - expect(getLocalizedBenefitsPlan(t, null)).toBe(''); - expect(getLocalizedBenefitsPlan(t, undefined)).toBe(''); - expect( - getLocalizedBenefitsPlan( - t, - 'nonsense' as MpdGoalBenefitsConstantPlanEnum, - ), - ).toBe(''); - }); -}); diff --git a/src/lib/functions/getLocalizedBenefitsPlan.ts b/src/lib/functions/getLocalizedBenefitsPlan.ts deleted file mode 100644 index 6719fd0863..0000000000 --- a/src/lib/functions/getLocalizedBenefitsPlan.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { TFunction } from 'react-i18next'; -import { MpdGoalBenefitsConstantPlanEnum } from 'src/graphql/types.generated'; - -/** - * Maps an MPD goal benefits plan to a localized, human-readable label. - */ -export const getLocalizedBenefitsPlan = ( - t: TFunction, - plan: MpdGoalBenefitsConstantPlanEnum | null | undefined, -): string => { - switch (plan) { - case MpdGoalBenefitsConstantPlanEnum.Select: - return t('Select'); - case MpdGoalBenefitsConstantPlanEnum.Plus: - return t('Plus'); - case MpdGoalBenefitsConstantPlanEnum.Base: - return t('Base'); - case MpdGoalBenefitsConstantPlanEnum.Minimum: - return t('Minimum'); - case MpdGoalBenefitsConstantPlanEnum.Exempt: - return t('Exempt'); - default: - return ''; - } -}; diff --git a/src/lib/functions/getLocalizedNsoHousing.test.ts b/src/lib/functions/getLocalizedNsoHousing.test.ts deleted file mode 100644 index a724ccf57c..0000000000 --- a/src/lib/functions/getLocalizedNsoHousing.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { NewStaffQuestionnaireNsoHousingEnum } from 'src/graphql/types.generated'; -import { getLocalizedNsoHousing } from './getLocalizedNsoHousing'; - -const t = (key: string) => key; - -describe('getLocalizedNsoHousing', () => { - it.each([ - [ - NewStaffQuestionnaireNsoHousingEnum.SingleRoom, - 'Single in hotel/dorm room', - ], - [ - NewStaffQuestionnaireNsoHousingEnum.SharedRoom, - 'Sharing 2 in hotel/dorm room', - ], - [ - NewStaffQuestionnaireNsoHousingEnum.CoupleRoom, - 'Couple in hotel/dorm room', - ], - [NewStaffQuestionnaireNsoHousingEnum.FamilyRoom, 'Family in a hotel/room'], - [NewStaffQuestionnaireNsoHousingEnum.LocalCommuting, 'Local / Commuting'], - ])('maps %s to "%s"', (housing, expected) => { - expect(getLocalizedNsoHousing(t, housing)).toBe(expected); - }); - - it('returns an empty string for null, undefined, or an unrecognized value', () => { - expect(getLocalizedNsoHousing(t, null)).toBe(''); - expect(getLocalizedNsoHousing(t, undefined)).toBe(''); - expect( - getLocalizedNsoHousing( - t, - 'nonsense' as NewStaffQuestionnaireNsoHousingEnum, - ), - ).toBe(''); - }); -}); diff --git a/src/lib/functions/getLocalizedNsoHousing.ts b/src/lib/functions/getLocalizedNsoHousing.ts deleted file mode 100644 index 9260d14374..0000000000 --- a/src/lib/functions/getLocalizedNsoHousing.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { TFunction } from 'react-i18next'; -import { NewStaffQuestionnaireNsoHousingEnum } from 'src/graphql/types.generated'; - -/** - * Maps a New Staff Orientation housing option to a localized, human-readable - * label (matching the New Staff Questionnaire wording). - */ -export const getLocalizedNsoHousing = ( - t: TFunction, - housing: NewStaffQuestionnaireNsoHousingEnum | null | undefined, -): string => { - switch (housing) { - case NewStaffQuestionnaireNsoHousingEnum.SingleRoom: - return t('Single in hotel/dorm room'); - case NewStaffQuestionnaireNsoHousingEnum.SharedRoom: - return t('Sharing 2 in hotel/dorm room'); - case NewStaffQuestionnaireNsoHousingEnum.CoupleRoom: - return t('Couple in hotel/dorm room'); - case NewStaffQuestionnaireNsoHousingEnum.FamilyRoom: - return t('Family in a hotel/room'); - case NewStaffQuestionnaireNsoHousingEnum.LocalCommuting: - return t('Local / Commuting'); - default: - return ''; - } -}; diff --git a/src/lib/functions/getLocalizedNsoSessions.test.ts b/src/lib/functions/getLocalizedNsoSessions.test.ts deleted file mode 100644 index 8371e459c4..0000000000 --- a/src/lib/functions/getLocalizedNsoSessions.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NewStaffQuestionnaireNsoSessionsEnum } from 'src/graphql/types.generated'; -import { getLocalizedNsoSessions } from './getLocalizedNsoSessions'; - -const t = (key: string) => key; - -describe('getLocalizedNsoSessions', () => { - it.each([ - [NewStaffQuestionnaireNsoSessionsEnum.IbsAndNso, 'IBS and NSO'], - [NewStaffQuestionnaireNsoSessionsEnum.Nso, 'NSO'], - ])('maps %s to "%s"', (sessions, expected) => { - expect(getLocalizedNsoSessions(t, sessions)).toBe(expected); - }); - - it('returns an empty string for null, undefined, or an unrecognized value', () => { - expect(getLocalizedNsoSessions(t, null)).toBe(''); - expect(getLocalizedNsoSessions(t, undefined)).toBe(''); - expect( - getLocalizedNsoSessions( - t, - 'nonsense' as NewStaffQuestionnaireNsoSessionsEnum, - ), - ).toBe(''); - }); -}); diff --git a/src/lib/functions/getLocalizedNsoSessions.ts b/src/lib/functions/getLocalizedNsoSessions.ts deleted file mode 100644 index 005e7ca245..0000000000 --- a/src/lib/functions/getLocalizedNsoSessions.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { TFunction } from 'react-i18next'; -import { NewStaffQuestionnaireNsoSessionsEnum } from 'src/graphql/types.generated'; - -/** - * Maps a New Staff Orientation sessions option to a localized, human-readable - * label (matching the New Staff Questionnaire wording). - */ -export const getLocalizedNsoSessions = ( - t: TFunction, - sessions: NewStaffQuestionnaireNsoSessionsEnum | null | undefined, -): string => { - switch (sessions) { - case NewStaffQuestionnaireNsoSessionsEnum.IbsAndNso: - return t('IBS and NSO'); - case NewStaffQuestionnaireNsoSessionsEnum.Nso: - return t('NSO'); - default: - return ''; - } -}; diff --git a/src/lib/functions/getLocalizedRole.test.ts b/src/lib/functions/getLocalizedRole.test.ts deleted file mode 100644 index 51f87b21d8..0000000000 --- a/src/lib/functions/getLocalizedRole.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { GoalCalculationRole } from 'src/graphql/types.generated'; -import { getLocalizedRole } from './getLocalizedRole'; - -const t = (key: string) => key; - -describe('getLocalizedRole', () => { - it.each([ - [GoalCalculationRole.Field, 'Field'], - [GoalCalculationRole.Office, 'Office'], - ])('maps %s to "%s"', (role, expected) => { - expect(getLocalizedRole(t, role)).toBe(expected); - }); - - it('returns an empty string for null, undefined, or an unrecognized value', () => { - expect(getLocalizedRole(t, null)).toBe(''); - expect(getLocalizedRole(t, undefined)).toBe(''); - expect(getLocalizedRole(t, 'nonsense' as GoalCalculationRole)).toBe(''); - }); -}); diff --git a/src/lib/functions/getLocalizedRole.ts b/src/lib/functions/getLocalizedRole.ts deleted file mode 100644 index 3b346e4f90..0000000000 --- a/src/lib/functions/getLocalizedRole.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { TFunction } from 'react-i18next'; -import { GoalCalculationRole } from 'src/graphql/types.generated'; - -/** - * Maps a goal calculation role to a localized, human-readable label. - */ -export const getLocalizedRole = ( - t: TFunction, - role: GoalCalculationRole | null | undefined, -): string => { - switch (role) { - case GoalCalculationRole.Field: - return t('Field'); - case GoalCalculationRole.Office: - return t('Office'); - default: - return ''; - } -}; diff --git a/yarn.lock b/yarn.lock index 3653dc30de..c84a67dcca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6861,6 +6861,18 @@ __metadata: languageName: node linkType: hard +"ajv@npm:^8.20.0": + version: 8.20.0 + resolution: "ajv@npm:8.20.0" + dependencies: + fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + checksum: 10/5ce59c0537f4c2aca9a758b412659ec70acb4d5dde971c10ecf21d2e3d799f99acdb4a08e1f5fb2e067c8542930398aae793bb996bb07d3feb81dae22fe2ada9 + languageName: node + linkType: hard + "ajv@npm:^8.6.0": version: 8.11.0 resolution: "ajv@npm:8.11.0" @@ -7464,6 +7476,13 @@ __metadata: languageName: node linkType: hard +"balanced-match@npm:^4.0.2": + version: 4.0.4 + resolution: "balanced-match@npm:4.0.4" + checksum: 10/fb07bb66a0959c2843fc055838047e2a95ccebb837c519614afb067ebfdf2fa967ca8d712c35ced07f2cd26fc6f07964230b094891315ad74f11eba3d53178a0 + languageName: node + linkType: hard + "bare-events@npm:^2.0.0, bare-events@npm:^2.2.0": version: 2.2.2 resolution: "bare-events@npm:2.2.2" @@ -7658,6 +7677,15 @@ __metadata: languageName: node linkType: hard +"brace-expansion@npm:^5.0.5": + version: 5.0.6 + resolution: "brace-expansion@npm:5.0.6" + dependencies: + balanced-match: "npm:^4.0.2" + checksum: 10/a7acf120fefa79e9d7c9c92898114f57c07596a3920197f3c5917e6a628b04220a5f7f9618c30bdd973a6576a32113b99f9c3f1c8245ccc399dd2a9a718d81d8 + languageName: node + linkType: hard + "braces@npm:^2.3.1": version: 2.3.2 resolution: "braces@npm:2.3.2" @@ -10702,6 +10730,13 @@ __metadata: languageName: node linkType: hard +"fast-uri@npm:^3.0.1": + version: 3.1.2 + resolution: "fast-uri@npm:3.1.2" + checksum: 10/1dff04865b2a38d3e0659deadfbf72efdf83a776bfbf9667e4aa9e5a3ec31bc341cda9622136b32b7652a857c8ba11896794186e8f876f8b2b72731fce8622f6 + languageName: node + linkType: hard + "fast-url-parser@npm:^1.1.3": version: 1.1.3 resolution: "fast-url-parser@npm:1.1.3" @@ -14484,6 +14519,15 @@ __metadata: languageName: node linkType: hard +"minimatch@npm:^10.2.5": + version: 10.2.5 + resolution: "minimatch@npm:10.2.5" + dependencies: + brace-expansion: "npm:^5.0.5" + checksum: 10/19e87a931aff60ee7b9d80f39f817b8bfc54f61f8356ee3549fbf636dbccacacfec8d803eac73293955c4527cd085247dfc064bce4a5e349f8f3b85e2bf5da0f + languageName: node + linkType: hard + "minimatch@npm:^3.0.2, minimatch@npm:^3.0.4, minimatch@npm:^3.0.5, minimatch@npm:^3.1.1, minimatch@npm:^3.1.2": version: 3.1.2 resolution: "minimatch@npm:3.1.2" @@ -14736,6 +14780,7 @@ __metadata: "@types/testing-library__jest-dom": "npm:^5.14.5" "@typescript-eslint/eslint-plugin": "npm:^7.5.0" "@typescript-eslint/parser": "npm:^8.17.0" + ajv: "npm:^8.20.0" apollo3-cache-persist: "npm:^0.14.1" clsx: "npm:^2.1.1" concurrently: "npm:^8.2.2" @@ -14769,6 +14814,7 @@ __metadata: lodash: "npm:^4.17.21" luxon: "npm:^3.4.4" micro-cors: "npm:^0.1.1" + minimatch: "npm:^10.2.5" next: "npm:^15.0.3" next-auth: "npm:^4.24.11" next-compose-plugins: "npm:^2.2.1" @@ -14794,6 +14840,7 @@ __metadata: typescript: "npm:~6.0.0" url-loader: "npm:^4.1.1" webpack: "npm:^5.96.1" + yaml: "npm:^2.9.0" yup: "npm:^1.4.0" languageName: unknown linkType: soft @@ -20328,6 +20375,15 @@ __metadata: languageName: node linkType: hard +"yaml@npm:^2.9.0": + version: 2.9.0 + resolution: "yaml@npm:2.9.0" + bin: + yaml: bin.mjs + checksum: 10/9a95e8e08651c3d292ab6a5befeb5f57b76801caa097c75bb45c9a70ce19c1b11f57e87a6ef84a579ea070ed2c2c8ac541c88c0ae684d544d5f42c7e77d11b7b + languageName: node + linkType: hard + "yargs-parser@npm:^13.1.2": version: 13.1.2 resolution: "yargs-parser@npm:13.1.2"