agentleFS
grzmolgrzmol/vibe-force/skills/sf-fflib-testing/SKILL.md

sf-fflib-testing

Skill2 starsChanged 2 months agoMITView on GitHub

Covers every testing variant available in an Apex Enterprise Patterns (fflib) codebase - ApexMocks stub lifecycle (fflib_ApexMocks, startStubbing/when/thenReturn/stopStubbing, verify and verification modes), argument matchers and fflib_ArgumentCaptor, mock injection through Application.UnitOfWork/Service/Selector/Domain setMock, fake SObject graphs with fflib_ApexMocksUtils and fflib_IDGenerator, DML-free domain trigger tests via fflib_SObjectDomain.Test.Database, real-DML selector and integration tests, async/Queueable/Batch/Schedulable tests, platform-event delivery with Test.getEventBus().deliver(), callout mocks, and FLS/CRUD negative tests. Use when writing or reviewing any Apex test in a repo containing fflib_ApexMocks, fflib_SObjectSelector, fflib_SObjectDomain, fflib_SObjectUnitOfWork or an Application class, when a mock verification fails, when choosing between a mocked unit test and a real-DML integration test, or when Apex coverage gates (apexOrgCoverageMin 85 / apexClassCoverageMin 75) must be met without writing coverage-padding tests.

The file

We split this skill into the rules it gives an agent

3 rules. About 0 are common, 1 is distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Read sf-fflib-foundations for the Application/factory wiring these tests inject into, sf-fflib-selector-layer for selector construction, …

    line 18
  2. Very specific:

    Use this skill when a repo contains fflib_ApexMocks, fflib_SObjectDomain, fflib_SObjectSelector, fflib_SObjectUnitOfWork, or an Application …

    line 24
  3. Distinctive:

    Every assertion you would have made on saved data must be re-proved by at least one real-DML test (rows 5, 6, 13).

    line 236

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

fflib Testing

Testing an fflib codebase means choosing, per class under test, between a mocked unit test (fast, proves collaboration, proves nothing about the org) and a real-DML test (slow, proves the platform actually behaves). Getting that choice wrong is the single largest source of worthless Apex test suites: 90% coverage where every selector's SOQL is unproven.

Framework grounding: fflib-apex-mocks @ master commit d81e9e1 and fflib-apex-common @ master commit dab5977 (both apex-enterprise-patterns). Platform grounding: Apex Developer Guide, Summer '26 / API version 67.0 (doc version 262.0) - the apiVersion in config/vibe-force.defaults.json.

Read sf-fflib-foundations for the Application/factory wiring these tests inject into, sf-fflib-selector-layer for selector construction, sf-fflib-domain-service-uow for the layers under test, and sf-fflib-operations for CI/packaging of the framework itself.

When to use

Use this skill when a repo contains fflib_ApexMocks, fflib_SObjectDomain, fflib_SObjectSelector, fflib_SObjectUnitOfWork, or an Application class, and you are:

  • writing a new test for a service, domain, selector, controller, trigger, or async job;
  • diagnosing an fflib_ApexMocks.ApexMocksException from verify(...) or when(...);
  • deciding whether a behaviour needs a mock or real DML;
  • raising coverage to clear the apex gate without adding tests that assert nothing.

For non-fflib Apex testing fundamentals (@IsTest, @TestSetup, System.runAs, Assert) see sf-apex-testing. For LWC component tests see sf-lwc-jest-testing. For limit budgets consumed by real-DML tests see sf-governor-limits.

Test-variant decision table

#VariantWhat it provesWhat it cannot proveSpeedOrg datafflib machineryCoverage contributionTypical failure mode
1Service unit test with mocksService orchestration: which selector/domain/UoW calls happen, in what shapeSOQL validity, triggers, FLS, validation rules~10-40 msnonefflib_ApexMocks, Application.*.setMock, fflib_MatchService impl class onlyOver-specified verify breaks on harmless refactor
2Domain unit test, no DML, direct constructionPure field logic on in-memory recordsTrigger routing, save order~5-20 msnonefflib_IDGenerator for ids; no mocks neededDomain classAsserting through the UoW mock instead of the record
3Domain trigger test via fflib_SObjectDomain.Test.DatabaseonApplyDefaults/onValidate/onBefore*/onAfter* routing and error registrationReal DML errors, validation rules, roll-ups, chained triggers~10-30 msnonefflib_SObjectDomain.Test.Database.onInsert/onUpdate/onDelete/onUndelete + fflib_SObjectDomain.triggerHandler(Type), fflib_SObjectDomain.ErrorsDomain + trigger handlerForgetting Errors.clearAll() between assertions in one method
4Selector test against real dataThe SOQL compiles, field list is right, filters and ordering workBehaviour of callers200 ms - 2 sinserted recordsReal selector, no mocks, fflib_QueryFactory outputSelector classMocking the selector - proves nothing
5Service integration test with real DMLEnd-to-end: service + domain + selector + UoW + triggers + validationIsolation of a single failure cause0.5-5 sinserted recordsApplication.UnitOfWork.newInstance(), no setMockEvery layer it crossesSlow suite; used as the only test style
6Trigger test with real DMLSave order, recursion, cross-object roll-ups, validation rulesNothing about isolated units0.5-3 sinserted recordsReal trigger + fflib_SObjectDomain.triggerHandlerTrigger + domainAssumes @TestSetup data survives Test.startTest() limits reset
7Controller / @AuraEnabled testWire adapter contract: return shape, AuraHandledException messagesClient rendering~20-80 ms mockednone if service mockedApplication.Service.setMock(IFooService.class, mock)Controller classTesting the service again through the controller
8Flow-invocable test (@InvocableMethod)Bulk request/response contract and per-row mappingFlow itself~20-80 msnone if service mockedApplication.Service.setMockInvocable classSingle-row-only test on a bulk API
9Async test: Queueable / SchedulableThe job enqueues, runs, and commits inside Test.startTest()/stopTest()Behaviour under real 2-minute chaining/limits0.5-3 susually yesReal UoW; mock the service the job calls where possibleJob classAsserting before Test.stopTest()
10Async test: Batchablestart/execute/finish wiring, one chunkMulti-chunk state, real scope sizes1-5 syesBatchable classes cannot be stubbed (Stub API limit) - mock its collaborators insteadBatch classExpecting finish after an unhandled execute exception in test context
11Platform-event testEvent publication and the subscriber triggerReal bus latency/replay0.3-2 ssometimesuow.registerPublishAfterSuccessTransaction, then Test.getEventBus().deliver()Publisher + subscriberMissing deliver(), so the subscriber never runs
12Callout testRequest shape and response handlingThe remote system~30-200 msnoneTest.setMock(HttpCalloutMock.class, ...) inside the mocked service boundaryCallout classCallout mock set after the callout is triggered
13FLS / CRUD negative testUSER_MODE DML and selector security actually rejectAnything under system mode0.5-3 suser + perm setfflib_SObjectUnitOfWork.UserModeDML, System.runAs, Assert.fail() + catch (SecurityException)Security pathsRunning as a System Administrator, so nothing is denied
14LWC Jest testClient behaviour, wire handling, DOMAny Apex~50-300 msnonenone (see sf-lwc-jest-testing)JS coverage (jestCoverageMin)Duplicating Apex assertions in JS

Rows 1-3, 7, 8 are the fast tier; rows 4-6, 9-13 are the slow tier. A healthy fflib org is roughly 70% fast tier / 25% slow tier / 5% end-to-end, described under "Test pyramid" below.

Core patterns

1. The ApexMocks lifecycle

fflib_ApexMocks implements System.StubProvider; mocks.mock(Type) returns Test.createStub(classToMock, this). The stub is named <Type>__sfdc_ApexStub, which is what you see in failure messages.

@IsTest
private class OpportunitiesServiceTest {
    @IsTest
    static void applyDiscountRegistersDirtyOpportunities() {
        // 1. Arrange the mock control object and the stubs
        fflib_ApexMocks mocks = new fflib_ApexMocks();
        IOpportunitiesSelector selectorMock = (IOpportunitiesSelector) mocks.mock(IOpportunitiesSelector.class);
        fflib_ISObjectUnitOfWork uowMock = (fflib_ISObjectUnitOfWork) mocks.mock(fflib_ISObjectUnitOfWork.class);

        Opportunity opp = new Opportunity(
            Id = fflib_IDGenerator.generate(Opportunity.SObjectType),
            Name = 'Test',
            StageName = 'Open',
            CloseDate = System.today(),
            Amount = 1000);
        Set<Id> oppIds = new Set<Id>{ opp.Id };

        // 2. Stub: everything between startStubbing/stopStubbing records, it does not execute
        mocks.startStubbing();
        mocks.when(selectorMock.sObjectType()).thenReturn(Opportunity.SObjectType);
        mocks.when(selectorMock.selectByIdWithProducts(oppIds)).thenReturn(new List<Opportunity>{ opp });
        mocks.stopStubbing();

        // 3. Inject
        Application.Selector.setMock(selectorMock);
        Application.UnitOfWork.setMock(uowMock);

        // 4. Exercise the real implementation class, not the static facade
        System.Test.startTest();
        new OpportunitiesServiceImpl().applyDiscounts(new Map<Id, Decimal>{ opp.Id => 10 });
        System.Test.stopTest();

        // 5. Verify collaboration
        ((IOpportunitiesSelector) mocks.verify(selectorMock, 1)).selectByIdWithProducts(oppIds);
        ((fflib_ISObjectUnitOfWork) mocks.verify(uowMock, 1)).registerDirty(
            fflib_Match.sObjectWith(new Map<SObjectField, Object>{
                Opportunity.Id => opp.Id,
                Opportunity.Amount => 900 }));
        ((fflib_ISObjectUnitOfWork) mocks.verify(uowMock, 1)).commitWork();
    }
}

Rules that follow from the source:

  • Every when(...) must be inside startStubbing()/stopStubbing(). Outside it, the call is recorded as a real invocation and then verified against later, corrupting counts.
  • when(...) must be followed by thenReturn/thenThrow/thenAnswer/thenReturnMulti/ thenThrowMulti. A bare when(...) yields ApexMocksException: The stubbing is not correct, no return values have been set. at call time.
  • Unstubbed methods return null, they do not throw. A NullPointerException deep inside the class under test almost always means "you forgot to stub something".
  • mocks.mock() uses the Stub API, so the platform's stub limitations apply: no static or private methods, no properties, no triggers, no inner classes, no system types, no Batchable implementers, no classes with only private constructors, and iterators cannot be a parameter or return type. The mocked type must be in the same namespace as the Test.createStub() call. (Apex Developer Guide, Build a Mocking Framework with the Stub API)

2. Generated mocks vs Stub API mocks

Two modes exist. Only the first is current.

ModeHow a mock is madeNeeds regeneration on interface changeUse
Stub API (current)(IFoo) mocks.mock(IFoo.class) -> Test.createStubNoDefault for everything
ApexMocks Generator (legacy)Pre-generated FooMocks.Foo class implementing the interface and delegating to mocks.mockNonVoidMethod(...) / mocks.mockVoidMethod(...)Yes - stale generated mocks silently driftOnly for types the Stub API refuses to stub

mockVoidMethod(Object, String, List<Type>, List<Object>) and mockNonVoidMethod(...) remain public on fflib_ApexMocks for generated classes. The generator itself is an external tool, not part of the fflib-apex-mocks repo.

fflib_ApexMocksConfig.HasIndependentMocks (default false) controls whether two mock instances of the same type share stubbing and counts. fflib_QualifiedMethod.equals/hashCode include the mock instance only when it is true. Set it to true in @IsTest setup when a single test holds two mocks of one interface and must distinguish them:

fflib_ApexMocksConfig.HasIndependentMocks = true;

3. Verification API

((IFoo) mocks.verify(fooMock)).bar();                                   // exactly 1
((IFoo) mocks.verify(fooMock, 3)).bar();                                // exactly 3
((IFoo) mocks.verify(fooMock, fflib_ApexMocks.NEVER)).bar();            // NEVER == 0
((IFoo) mocks.verify(fooMock, mocks.atLeastOnce())).bar();
((IFoo) mocks.verify(fooMock, mocks.atLeast(2))).bar();
((IFoo) mocks.verify(fooMock, mocks.atMost(5))).bar();
((IFoo) mocks.verify(fooMock, mocks.between(1, 3))).bar();
((IFoo) mocks.verify(fooMock, mocks.times(2).description('two writes expected'))).bar();

fflib_InOrder inOrder = mocks.inOrder(new List<Object>{ fooMock, uowMock });
((IFoo) inOrder.verify(fooMock)).bar();
((fflib_ISObjectUnitOfWork) inOrder.verify(uowMock)).commitWork();
inOrder.verifyNoMoreInteractions();

Verified against source: modes are times, calls, atLeast, atMost, atLeastOnce, between, never, plus description(String) for a custom assert message. calls(n) is InOrder-only (fflib_AnyOrder throws The calls() method is available only in the InOrder Verification.); atMost and between are not available in InOrder (The <mode> method is not implemented for the fflib_InOrder class). verifyNoMoreInteractions() and verifyNoInteractions() exist only on fflib_InOrder, not on fflib_ApexMocks; there is no verifyZeroInteractions. Use mocks.verify(mock, fflib_ApexMocks.NEVER) per method for the non-ordered equivalent.

Full API table: references/apexmocks-api.md.

4. Matchers, captors, answers

All-or-nothing rule: if one argument uses a matcher, every argument must. Mixing raw values with matchers throws The number of matchers defined (n). does not match the number expected (m).

((IFoo) mocks.verify(fooMock)).save(fflib_Match.anyId(), fflib_Match.eqString('OPEN'));

fflib_ArgumentCaptor captor = fflib_ArgumentCaptor.forClass(List<Account>.class);
((fflib_ISObjectUnitOfWork) mocks.verify(uowMock)).registerDirty((List<SObject>) captor.capture());
List<Account> registered = (List<Account>) captor.getValue();
Assert.areEqual(2, registered.size(), 'Both accounts should be registered dirty');

Dynamic returns and side effects use fflib_Answer; void-method throwing uses mocks.doThrowWhen(e, mock) inside stubbing. See references/matchers-and-captors.md for the complete matcher catalogue, each matcher's toString() (which is what the failure message prints) and custom fflib_IMatcher implementations.

5. Fake SObjects: ids, relationships, read-only fields

Account acct = new Account(Id = fflib_IDGenerator.generate(Account.SObjectType), Name = 'A');

// Parent with children, as if returned by a subquery
List<Account> withContacts = (List<Account>) fflib_ApexMocksUtils.makeRelationship(
    List<Account>.class,
    new List<Account>{ acct },
    Contact.AccountId,
    new List<List<Contact>>{ new List<Contact>{ new Contact(LastName = 'X') } });

// Formula / rollup / system audit fields
acct = (Account) fflib_ApexMocksUtils.setReadOnlyFields(
    acct, Account.class, new Map<SObjectField, Object>{ Account.LastActivityDate => Date.today() });

makeRelationship and setReadOnlyFields both work by serialising to JSON, injecting tokens, and deserialising - which is exactly why they can set fields SObject.put() rejects. Details and the raw JSON.deserialize fallback: references/fake-data-and-ids.md.

6. Mocking the Unit of Work

Mocking fflib_ISObjectUnitOfWork replaces "query the database and assert" with "assert the DML intent". It is fast and precise, and it is blind to:

  • triggers that would have fired on commitWork();
  • validation rules, required fields, and duplicate rules;
  • FLS/CRUD, because SimpleDML runs AccessLevel.SYSTEM_MODE and UserModeDML runs AccessLevel.USER_MODE - a mock runs neither;
  • the real commit order (publishBefore events -> insert -> upsert -> update -> delete -> emptyRecycleBin -> email -> doWork -> publishAfterSuccess events);
  • Database.SaveResult, which a mocked UoW never produces.

Version boundary that matters here: in API version 67.0 and later Apex runs in user context by default, so object permissions and FLS are enforced unless an operation explicitly opts into system mode; WITH SECURITY_ENFORCED is no longer allowed in Apex SOQL (use WITH USER_MODE), and a class without an explicit sharing declaration behaves as with sharing. fflib_SObjectUnitOfWork.SimpleDML passes AccessLevel.SYSTEM_MODE explicitly, so it still bypasses CRUD/FLS; UserModeDML passes AccessLevel.USER_MODE. Neither path executes under a mock. See sf-security-model.

Every assertion you would have made on saved data must be re-proved by at least one real-DML test (rows 5, 6, 13). State that explicitly in the test's header comment.

7. Domain trigger tests without DML

fflib_SObjectDomain.Test.Database feeds the trigger handler synthetic Trigger.new / Trigger.oldMap data. fflib_SObjectDomain.triggerHandler(Type) detects Test.isRunningTest() && Test.Database.hasRecords() and runs the before phase then the after phase against that data.

@IsTest
static void insertValidationFailsWithoutAccount() {
    Opportunity opp = new Opportunity(Name = 'Test', Type = 'Existing Account');
    fflib_SObjectDomain.Test.Database.onInsert(new List<Opportunity>{ opp });
    fflib_SObjectDomain.triggerHandler(OpportunitiesTriggerHandler.class);

    Assert.areEqual(1, fflib_SObjectDomain.Errors.getAll().size(), 'One validation error expected');
    Assert.areEqual(
        'You must provide an Account for existing Customers.',
        fflib_SObjectDomain.Errors.getAll()[0].message);
    Assert.areEqual(
        Opportunity.AccountId,
        ((fflib_SObjectDomain.FieldError) fflib_SObjectDomain.Errors.getAll()[0]).field);
}

Available: onInsert(List<SObject>), onUpdate(List<SObject>, Map<Id, SObject>), onDelete(Map<Id, SObject>), onUndelete(List<SObject>), hasRecords(). fflib_SObjectDomain.Errors exposes getAll() and clearAll().

This proves routing and error registration only. It does not prove the error surfaces as a DML error, that the trigger is deployed and active, or that the record was rejected. Pair it with one real-DML trigger test per validation that matters.

8. Where mocks are the wrong tool

ConcernWhy a mock cannot prove itCorrect variant
Selector SOQLThe query string is never parsed by the platform4 - real records, real selector
Validation rules / required fieldsMetadata, evaluated only on real DML5, 6
Sharing, CRUD, FLSEnforced by USER_MODE/WITH USER_MODE at runtime13, under System.runAs
Save order, roll-up summaries, recursionPlatform execution order6
Flow / record-triggered automationRuns outside Apex5 or 6
Platform-event subscriptionRequires the event bus11

9. Test pyramid and coverage gates

Gates enforced by vf-check apex: apexOrgCoverageMin 85, apexClassCoverageMin 75. Mocked unit tests cover the class under test, never its mocked collaborators - so a suite of only mocked tests leaves selectors at 0%.

LayerVariantShare of test methodsCovers
Domain / service logic1, 2, 3, 7, 8~70%Domain, service impl, controllers, invocables
Selectors + integration + security4, 5, 13~25%Selectors, triggers, UoW, security paths
Async, events, callouts9, 10, 11, 12~5%Jobs, subscribers, HTTP boundaries

Per-class rule of thumb for the 75% class gate: every selector needs at least one real-data test, every domain needs a no-DML test, every service impl needs a mocked test plus one integration test across its happy path. See sf-apex-testing for the general coverage strategy and sf-code-analyzer-quality for the static rules that flag assertion-free tests.

Anti-patterns

Anti-patternFailing shapeFix
Over-mocked test asserting implementationverify on ten methods including private helpers exposed only for the testVerify the boundary calls (selector in, UoW out) and assert record state
Mocking the class under testIFooService svc = (IFooService) mocks.mock(IFooService.class); svc.doIt(); then verifying doItInstantiate the real FooServiceImpl; mock only its collaborators
Missing stopStubbing()Later real calls are swallowed as stubs; verify counts are 0Always bracket: startStubbing() ... stopStubbing()
Bare when(...) with no thenReturnApexMocksException: The stubbing is not correct, no return values have been set.Add a thenReturn/thenThrow/thenAnswer
Mixing matchers and literalsThe number of matchers defined (1). does not match the number expected (2)Wrap every argument: fflib_Match.eq(value)
Stale generated mocks after an interface changeGenerated mock compiles but silently no longer overrides the new methodMigrate to mocks.mock(IFoo.class) (Stub API)
@IsTest(SeeAllData=true)Suite passes locally, fails in a fresh scratch orgBuild data in the test or @TestSetup
One @TestSetup shared by mocked and DML testsMocked tests pay the DML cost and get non-deterministic idsSplit into two test classes
Asserting Database.SaveResult off a mocked UoWA mocked UoW never executes DML, so results are nullAssert registerNew/registerDirty interactions, or use a real-DML test
Test that passes with the framework bypassedCalls new FooServiceImpl() but never injects, so production Application wiring is unverifiedRoute through Application.Service.newInstance(...) in at least one test
Coverage-padding testsnew Foo(); Assert.isNotNull(foo);Delete them; they defend nothing and hide the real gap
System.assertEquals in new codeLegacy assertion API without a messageUse Assert.areEqual(expected, actual, message)

Verification

# Full Apex gate: runs the org test level configured for the target and enforces coverage gates
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" apex --target-org vf-dev

# Single test method while iterating
sf apex run test --target-org vf-dev \
  --tests OpportunitiesServiceTest.applyDiscountRegistersDirtyOpportunities \
  --code-coverage --result-format json --wait 30

# A whole class, synchronously (fastest feedback for mocked tests)
sf apex run test --target-org vf-dev --class-names OpportunitiesServiceTest \
  --synchronous --code-coverage --result-format human --wait 20

# A curated suite (ApexTestSuite metadata) for the fast tier
sf apex run test --target-org vf-dev --suite-names FflibUnitTests \
  --code-coverage --result-format json --wait 30

# Local gate before any org round-trip
node "${CLAUDE_PLUGIN_ROOT}/scripts/checks/vf-check.mjs" local

Reading a failed mock verification - the message is produced by fflib_MethodVerifier.throwException:

EXPECTED COUNT: 1
ACTUAL COUNT: 0
METHOD: OpportunitiesSelector__sfdc_ApexStub.selectByIdWithProducts(Set<Id>)
---
ACTUAL ARGS: ({"..."} )
---
EXPECTED ARGS: [[SObject with fields {"Amount":900}]]

ACTUAL COUNT: 0 with a populated ACTUAL ARGS means the method ran with different arguments - compare the two blocks. ACTUAL ARGS: () means the method never ran at all: check injection (Application.*.setMock) before blaming the matcher. EXPECTED ARGS renders each matcher's toString(); raw values render as JSON.

References

  • references/apexmocks-api.md - every public class/method in fflib-apex-mocks with signature, purpose and version note.
  • references/matchers-and-captors.md - full fflib_Match / fflib_MatcherDefinitions catalogue, rendered failure text, fflib_ArgumentCaptor, custom matchers.
  • references/mock-injection.md - Application.*.setMock variants, mockable interface design, controller/domain/service injection, HasIndependentMocks.
  • references/test-variants-cookbook.md - one complete test class per decision-table row.
  • references/fake-data-and-ids.md - fflib_IDGenerator, makeRelationship, setReadOnlyFields, JSON tricks, dual-mode builders.
  • references/async-and-events-testing.md - Queueable/Batch/Schedulable, Test.startTest/stopTest flush, Test.getEventBus().deliver(), finalizers, callout mocks.
  • references/troubleshooting-mocks.md - exception text -> cause -> fix, plus a debugging procedure.
  • references/migrating-legacy-tests.md - converting DML-heavy suites to the fflib pyramid without losing coverage; run it as a multi-wave change per sf-workflow-orchestration.

Official documentation:

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.