Skip to content

[Due for payment 2026-10-28] [$500] [Refactor] Refactor PolicyUtils.ts to stay under the 4000-line ESLint limit #102675

Open

Description

@blimpich

Coming from here, PolicyUtils.ts has gotten close to it's max size, which leads to preventable issues where we go over the limit and then suddenly the build is broken, blocking deploys and other people's PRs.

Solution

Let's break up the file as per @nkdengineer's plan here.

Issue OwnerCurrent Issue Owner: @nkdengineer
Upwork Automation - Do Not Edit
Issue OwnerCurrent Issue Owner: @nkdengineer

Activity

  1. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on Sep 30, 2026
  2. melvin-bot commented on Sep 30, 2026

    @melvin-bot

    Current assignee @nkdengineer is eligible for the External assigner, not assigning anyone new.

  3. changed the title [-][Refactor] Refactor PolicyUtils.ts to stay under the 4000-line ESLint limit[/-] [+][$175] [Refactor] Refactor PolicyUtils.ts to stay under the 4000-line ESLint limit[/+] on Sep 30, 2026
  4. melvin-bot commented on Sep 30, 2026

    @melvin-bot
  5. nkdengineer commented on Sep 30, 2026

    @nkdengineer
    Contributor

    Split plan

    Sizes are the current function bodies, not a promise about the formatted result. Scattered helpers are called out so a move is not "cut this line range" when the functions are not contiguous.

    New file What moves Where it is now ~Size Imports
    types.ts Exported types MemberEmailsToAccountIDs, PolicyFeature, PolicyFeatureAccess, plus types used by more than one module (PolicyWithTaxRates, AccountingConnectionName, WorkspaceDetails, ApprovalWorkflowContext, ApprovalWorkflowRuleMatch, TravelStep) 72-111 ~40 nothing
    policyType.ts Archived / pending-delete checks, paid vs Submit vs group vs Control vs Collect, canPolicyAccessFeature, isSubmitterApproveBlockedOnSubmitWorkspace 115-139, 1544-1585, 1615-1622, 1627-1635, 1659-1689 ~120 types
    permissions.ts Role checks (isPolicyAdmin, getPolicyRole, editor / auditor / user / employee), feature-permission bundles, canMember*, canEditWorkspaceSettings, isPolicyApprover, getPolicyApproverLogins 187-336, 689-713, 731-747, 821-848, 909-931, 1586-1614 ~275 policyType
    tags.ts Tag lists, parent-tag matching, GL code, dependent / independent tags, getTagApproverRule 1167-1308, 1403-1543, 2387-2440, 3394-3406 ~350 nothing
    rules.ts hasCustomCategories, isMaxExpenseAmountSet, hasConfiguredRules, getRulesDocumentSourceURL 1309-1402, 3752-3774 ~120 nothing
    customUnits.ts Distance and per diem units, rate display, duplication (getCustomUnitsForDuplication, cloneCustomUnitWithNewIDs) 398-482, 528-663, 2799-2807 ~230 policyType
    connections.ts Current connection, auto-sync, unsupported / OldDot-only integrations, last successful sync, exporters, isPreferredExporter, Uber receipt-partner error 850-854, 1840-1847, 2685-2711, 2820-2835, 3326-3356, 3650-3656, 3699-3714 ~110 nothing
    vendors.ts Vendor matching for every integration, vendor display / empty state, Xero suppliers, Campfire / DualEntry / Certinia vendor lists 2836-3325 ~490 nothing
    netsuite.ts NetSuite account, vendor, tax, and custom-field option builders 2495-2684 ~190 nothing
    sageIntacct.ts Sage Intacct entity, bank, expense, vendor, and credit-card options 2712-2777 ~65 nothing
    xero.ts Xero tenants, org name, bank and expense accounts, hasPolicyWithXeroConnection 2367-2371, 2441-2473 ~40 nothing
    tax.ts isTaxTrackingEnabled, tax-rate maps, canDisableOrDeleteTaxRate, getCurrentTaxID, resolveCurrentTaxCode, isPolicyTaxEnabled, isTaxCodeCustomized 1699-1720, 1853-1910, 3365-3393, 3731-3739, 3775 ~130 customUnits
    features.ts isPolicyFeatureEnabled and the feature checks it fans out to (arePolicyRulesEnabled, invoice fields, attendee tracking, time tracking, MCP) 1650-1658, 1911-1969, 3715-3730 ~85 policyType, connections
    workflows.ts Instant / delayed submit, auto-reporting frequency, isSubmitAndClose, dynamic external workflow 1721-1766, 1821-1839 ~65 connections
    errors.ts Policy error flags, brick-road status, the two selector wrappers at 682 and 687, areSettingsInErrorFields, settingsPendingAction 338-396, 665-687, 2474-2494 ~105 permissions, connections
    payments.ts Reimburser, PAYER_ROLES, canRolePay, isPolicyPayer, reimbursement choice, payments enabled, auto-pay availability 748-820, 1767-1791 ~100 permissions
    approvals.ts Approval mode, default approver, custom workflows, rule matching, submit-to / forwards-to, getWorkflowApprovalsUnavailable 1792-1820, 1971-2334, 3415-3418 ~400 workflows, policyType, permissions, tags
    workspaces.ts Active-policy filters, shouldShowPolicy, owned / group / chat-enabled policies, admin and employee workspaces, plan type, deletion guard, sort helpers, and the three invoice helpers (canSendInvoiceFromWorkspace, canSubmitPerDiemExpenseFromWorkspace, canSendInvoice) 141-186, 714-730, 1623-1626, 2335-2366, 2372-2386, 2778-2798, 3361-3364, 3419-3579, 3740-3751 ~310 policyType, permissions, customUnits, features
    members.ts Expensify-team filtering, invite exclusions, guides and account managers, workspace member account IDs, isDeletedPolicyEmployee, and bank-account share recipients 483-527, 856-908, 932-1166, 3357-3360 ~340 permissions, workspaces
    domains.ts Policy domain name, admin private domains, most frequent domain, domain-card description 3407-3414, 3580-3649 ~80 members
    travel.ts Travel provisioning, accepted terms, non-USD, getTravelStep 3657-3698 ~40 policyType, workspaces, domains
    navigation.ts tryNavigateToSubmitWorkspaceUpgrade, tryNavigateToControlPolicyUpgrade, goBackFromInvalidPolicy, goBackWhenEnableFeature, navigateToExpensifyCardPage 1636-1649, 1690-1698, 1848-1852, 2808-2819 ~40 policyType
    index.ts Re-exports only, including the existing alias getAllTaxRatesNamesAndKeys as getAllTaxRates and export type {MemberEmailsToAccountIDs, PolicyFeature, PolicyFeatureAccess} 3784-4039 ~300 the modules above

    index.ts at ~300 lines of re-exports is the public API. That is the point: callers and jest.mock('@libs/PolicyUtils') stay on the barrel.

    Internal-only helpers stay private to their module. They are not in the export block today: the feature-permission maps, isControlPolicyOnlyRole, hasPolicyFeaturePermission, shouldShowPolicyErrorFields, getNumericValue, getDefaultDistanceRate, cloneCustomUnitWithNewIDs, the parent-tag regexes, isSubmitPolicyByType, isPolicyEditor, isControlOnAdvancedApprovalMode, getFirstRuleApprover and the approval-filter matchers, getAccountingConnectionNames, isAccountingConnectionName, the per-integration is*VendorMatchingActive checks that are not already exported, getActiveVendorMatchingVendors, and hasUnsupportedIntegration.

    Suggested PR order

    One PR that touches all 4039 lines will conflict with every open policy PR. Split it. Each step leaves index.ts re-exporting the moved names, so no importer changes.

    1. tags.ts. Leaf, ~350 lines. This alone drops index.ts under 4000 and clears the lint failure. getRuleApprovers (still in the old file) imports getTagApproverRule from ./tags, not the other way around.
    2. vendors.ts. Largest leaf, ~490 lines. The mention of getSageIntacctVendors inside it is a comment, not a call, so it does not need sageIntacct.ts yet.
    3. policyType.ts, then permissions.ts. policyType.ts is a leaf. Extract it first in this PR, then move permissions.ts so it imports ./policyType. Do not extract permissions.ts while isControlPolicy / isArchivedPolicy / isSubmitPolicy still live in index.ts.
    4. connections.ts, netsuite.ts, sageIntacct.ts, xero.ts. All leaves. ~400 lines together, or one PR per integration if that reviews more easily.
    5. customUnits.ts and tax.ts. tax.ts imports getDistanceRateCustomUnit from ./customUnits.
    6. rules.ts, features.ts, workflows.ts, errors.ts. These sit on policyType and connections, which already exist.
    7. approvals.ts and payments.ts. Riskiest call graph (getSubmitToEmail, rule matching). Do it only after workflows, permissions, and tags are real modules.
    8. workspaces.ts (including the three invoice helpers), members.ts (including bank-account sharing), domains.ts, travel.ts, navigation.ts. This empties the old file. index.ts becomes the barrel.
  6. nkdengineer commented on Sep 30, 2026

    @nkdengineer
    Contributor

    @MelvinBot Here is the split-file plan for PolicyUtils. Let's implement the first PR based on Suggested PR order section.

  7. MelvinBot commented on Sep 30, 2026

    @MelvinBot
    Contributor

    ⏱️ Ran out of time before I could finish. This usually means the task was too complex or I got stuck exploring. Please try again with a more specific request or post in #melvinbot-ai.

    Run details

  8. camesenin commented on Sep 30, 2026

    @camesenin

    Contributor details
    Your Expensify account email: [email protected]
    Upwork Profile Link: https://www.upwork.com/freelancers/~0170d6b02e9440e604

    Proposal

    Please re-state the problem that we are trying to solve in this issue.

    src/libs/PolicyUtils.ts has grown close to its maximum size, putting it at immediate risk of violating the ESLint 4,000-line ceiling and causing CI build failures that block deploys and subsequent PRs.

    What is the root cause of that problem?

    PolicyUtils.ts currently aggregates helper functions across diverse domains (tag manipulation, permission evaluation, connection utilities, vendor matching, custom units, and workflows) in a single monolithic file without submodule decomposition.

    What changes do you think we should make in order to solve the problem?

    Execute Step 1 of the maintainer's architectural roadmap established by @nkdengineer:

    1. Extract tags.ts into src/libs/PolicyUtils/tags.ts:

      • Extract the ~350 lines of self-contained tag helpers:
        • Tag lists and sorting: getTagLists, getTagList, getTagListName, getTagListByOrderWeight, getTagListLabel
        • Tag validation and properties: getTagNamesFromTagsLists, getTagApproverRule, getTagGLCode, hasDependentTags, hasEnabledTags, getCountOfEnabledTagsOfList, hasEnabledOptions
        • Private tag helpers: getCleanedTagName, parent-tag regex matchers.
      • Note that tags.ts is a pure leaf module requiring zero unextracted policy dependencies, ensuring clean separation.
    2. Preserve Public API & Re-exports:

      • In PolicyUtils.ts (or the PolicyUtils/index.ts barrel), re-export all extracted tag functions and types so that existing callers, consumers across the app, and jest.mock('@libs/PolicyUtils') remain 100% backward compatible without breaking changes or monorepo-wide import updates.
      • In PolicyUtils.ts, import getTagApproverRule from ./PolicyUtils/tags (or ./tags) for getRuleApprovers.
    3. Validation & Verification:

      • Verify that PolicyUtils.ts drops well below the 4,000-line threshold (~3,500 lines), permanently resolving the ESLint limit danger for this stage.
      • Run the complete test suite (npm test -- PolicyUtilsTest) and ESLint checks (npm run lint) to ensure zero regressions and 100% green test passes.

    What alternative solutions did you consider?

    Attempting to refactor all 4,000 lines into 17 submodules in a single massive PR. This was ruled out because a single monolithic refactor PR would inevitably produce merge conflicts with every other in-flight PR touching policy code. Starting with tags.ts as a pure leaf is the safest, conflict-free, incremental strategy.

  9. melvin-bot commented on Sep 30, 2026

    @melvin-bot

    ✅ Contributor details stored successfully. Thank you for contributing to Expensify!

  10. nkdengineer commented on Oct 1, 2026

    @nkdengineer
    Contributor

    Turn the file into a folder

    src/libs/PolicyUtils.ts is still one file. A sibling tags.ts cannot sit next to it until the module is a directory.

    1. Move src/libs/PolicyUtils.ts to src/libs/PolicyUtils/index.ts.
    2. Add src/libs/PolicyUtils/tags.ts.
    3. Leave every existing import ... from '@libs/PolicyUtils' and from './PolicyUtils' as it is. Both resolve to index.ts.

    index.ts keeps every current export, including getAllTaxRatesNamesAndKeys as getAllTaxRates. Callers and jest.mock('@libs/PolicyUtils') stay on that barrel. Nothing outside this folder should import @libs/PolicyUtils/tags.

    What moves into tags.ts

    Copy these functions unchanged. They only call each other, CONST, isEmptyObject, Str.htmlDecode, and getTagArrayFromName from @libs/TransactionUtils.

    Block Lines now Functions
    Tag lists and parent matching 1167-1304 getSortedTagKeys, getTagListName, getTagLists, hasTags, matchesParentTagsFilter, matchesParentTagPath, findPolicyTagAtLevel, findPolicyTagEntryByParentFilter, isTagInPolicy
    Tag name, GL code, counts 1400-1542 getTagList, getTagListByOrderWeight, getTagNamesFromTagsLists, getCleanedTagName, getCommaSeparatedTagNameWithSanitizedColons, getLengthOfTag, getTagGLCode, getGLCodeFromPolicyTag, escapeTagName, isDefaultTagName, getCountOfEnabledTagsOfList, getCountOfRequiredTagLists, isMultiLevelTags
    Dependent tags 2387-2438 hasDependentTags, hasIndependentTags, hasPerTagListRequired, getTagListLabel
    Tag approver rule 3204-3215 getTagApproverRule

    Keep LITERAL_PARENT_TAGS_FILTER and ESCAPED_CHARACTER private in tags.ts. They are not exported today.

    Leave the block between the first two tag sections in index.ts: hasCustomCategories, isMaxExpenseAmountSet, and hasConfiguredRules (1306-1398). Those belong to rules.ts in a later PR.

    Start tags.ts with a short file-header comment describing tag-list lookup, parent-tag matching, and GL codes. Export the 27 functions above. index.ts imports them from ./tags and keeps their names in the existing export { ... } block.

    The one edge back into index.ts

    getRuleApprovers (line 2016) stays in index.ts and calls getTagApproverRule. After the move, that call comes from the ./tags import. tags.ts does not import index.ts, so there is no cycle and no import/no-cycle disable.

    index.ts should land around 3450 lines, under the 4000-line max-lines cap.

  11. 83 remaining items

  12. melvin-bot commented on Oct 19, 2026

    @melvin-bot

    @nkdengineer

    The solution for this issue has been 🚀 deployed to production 🚀 in version 9.5.13-6 and is now subject to a 7-day regression period 📆. Here is the list of pull requests that resolve this issue:

    If no regressions arise, payment will be issued on 2026-10-26. 🎊

    The following checklist (instructions) will need to be completed before the issue can be closed. Please copy/paste the Contributor+ Checklist from here into a new comment on this GH and complete it. If you have the K2 extension, you can simply click: [this button]. If no checklist is needed for this issue, you can click: [no checklist button]

  13. changed the title [-][Due for payment 2026-10-26] [$175] [Refactor] Refactor PolicyUtils.ts to stay under the 4000-line ESLint limit[/-] [+][Due for payment 2026-10-27] [$175] [Refactor] Refactor PolicyUtils.ts to stay under the 4000-line ESLint limit[/+] on Oct 20, 2026
  14. melvin-bot commented on Oct 20, 2026

    @melvin-bot

    @nkdengineer

    The solution for this issue has been 🚀 deployed to production 🚀 in version 9.5.14-3 and is now subject to a 7-day regression period 📆. Here is the list of pull requests that resolve this issue:

    If no regressions arise, payment will be issued on 2026-10-27. 🎊

  15. changed the title [-][Due for payment 2026-10-27] [$175] [Refactor] Refactor PolicyUtils.ts to stay under the 4000-line ESLint limit[/-] [+][Due for payment 2026-10-28] [$175] [Refactor] Refactor PolicyUtils.ts to stay under the 4000-line ESLint limit[/+] on Oct 21, 2026
  16. melvin-bot commented on Oct 21, 2026

    @melvin-bot

    @nkdengineer

    The solution for this issue has been 🚀 deployed to production 🚀 in version 9.5.15-3 and is now subject to a 7-day regression period 📆. Here is the list of pull requests that resolve this issue:

    If no regressions arise, payment will be issued on 2026-10-28. 🎊

  17. nkdengineer commented on Oct 22, 2026

    @nkdengineer
    Contributor

    Not overdue. All PRs are complete.

  18. nkdengineer commented on Oct 22, 2026

    @nkdengineer
  19. blimpich commented on Oct 22, 2026

    @blimpich
    ContributorAuthor

    @nkdengineer I'm willing to increase the payout but not nearly by that much. Largely because this was a refactor PR that primarily was just moving code from one file to another. Yes there was testing involved but it was clear from the beginning how much work this was going to be and asking for an 8.5x increase in pay is a lot. How about $500 total?

  20. nkdengineer commented on Oct 22, 2026

    @nkdengineer
    Contributor

    @blimpich Thanks for being open to raising it. I understand the concern about the jump size. Would you consider $750 total ($250 per PR across the 3 PRs)?

    The code changes were mostly moving functions between files, as you said. Most of the time went into testing.

  21. blimpich commented on Oct 22, 2026

    @blimpich
    ContributorAuthor

    I think $500 is the max I would pay out for this. I appreciate the work but I would encourage you to seek a higher payout earlier in the process rather than at the end if it's going to be that big of an increase 👍

  22. changed the title [-][Due for payment 2026-10-28] [$175] [Refactor] Refactor PolicyUtils.ts to stay under the 4000-line ESLint limit[/-] [+][Due for payment 2026-10-28] [$500] [Refactor] Refactor PolicyUtils.ts to stay under the 4000-line ESLint limit[/+] on Oct 22, 2026
  23. melvin-bot commented on Oct 22, 2026

    @melvin-bot

    Upwork job price has been updated to $500

  24. nkdengineer commented on Oct 22, 2026

    @nkdengineer
    Contributor

    Thanks. I noted that.

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

Metadata

Metadata

Labels

DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions