agentleFS
grzmolgrzmol/vibe-force/skills/sf-project-structure/SKILL.md

sf-project-structure

Skill2 starsChanged 2 months agoMITView on GitHub

Structuring a Salesforce DX repository — the full `sfdx-project.json` schema (packageDirectories, default, sourceApiVersion, namespace, sfdcLoginUrl, plugins, packageAliases, replacements, sourceBehaviorOptions, oauthLocalPort, pushPackageDirectoriesSequentially), source-format directory layout for every metadata type (`objects/<Obj>/fields`, `classes`, `triggers`, `lwc`, `aura`, `flows`, `permissionsets`, `layouts`, `flexipages`, `staticresources`, `labels`, `settings`), decomposed metadata types, multiple package directories and modularisation, `.forceignore` semantics and precedence, `package.xml` and `destructiveChangesPre/Post.xml` manifests with `sf project generate manifest`, the source-tracking model (`sf project deploy|retrieve start|preview`, `delete source`, `reset tracking`, `--ignore-conflicts`), source/metadata format conversion, team repo conventions, and the `.vibeforce/` layout. Use this skill when creating or reorganising a DX project, deciding where a metadata file belongs, writing or auditing `.forceignore` or a manifest, resolving source-tracking conflicts, splitting a monolith into package directories, or reviewing metadata XML diffs.

The file

We split this skill into the rules it gives an agent

1 rule. About 0 are common, 0 are distinctive, and 1 is very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 1

    Very specific

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

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

    Keep sourceApiVersion equal to the vibe-force apiVersion (config/vibe-force.defaults.json) and to org-api-version.

    line 101

≈ “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

Salesforce DX Project Structure

A directory is a Salesforce DX project because it contains sfdx-project.json. That file tells the CLI which directories hold source (packageDirectories), what metadata shape to speak (sourceApiVersion), and where to log in (sfdcLoginUrl).

When to use

SituationAction
Creating a repo or adding a package directorysfdx-project.json schema + multiple-package-directory rules below
"Where does this metadata file go?"references/metadata-directory-map.md
Source keeps getting deployed that should not be.forceignore precedence, pattern 4, and sf project list ignored
Building a deployment or deletion manifestsf project generate manifest, references/forceignore-and-manifests.md
sf project deploy start reports conflictsPattern 5 — resolve, then deploy or retrieve with --ignore-conflicts
Importing an existing metadata-format reposf project convert mdapi (pattern 6)
Setting team conventions, review rules, branch modelreferences/repo-conventions.md

Related skills: sf-cli-operations (flags, config, API version resolution), sf-deployment-strategies (deploy/validate/quick-deploy mechanics and test levels), sf-scratch-orgs-sandboxes (org lifecycle, source tracking availability), sf-packaging-release (package directories becoming 2GP/unlocked packages), sf-security-model (profile vs permission-set policy).

Canonical layout

my-project/
├── sfdx-project.json            project definition (commit)
├── .forceignore                 what the project commands exclude (commit)
├── .gitignore                   what git excludes (commit)
├── package.json                 LWC Jest, eslint, prettier dev deps (commit)
├── jest.config.js               sfdx-lwc-jest config (commit)
├── config/
│   ├── project-scratch-def.json scratch org definition (commit)
│   └── dev-sandbox-def.json     sandbox definition (commit)
├── manifest/
│   ├── package.xml              deployment manifest (commit)
│   └── destructiveChangesPost.xml (commit when a release deletes metadata)
├── force-app/                   default package directory
│   └── main/default/
│       ├── classes/             ApexClass: Foo.cls + Foo.cls-meta.xml
│       ├── triggers/            ApexTrigger: Foo.trigger + Foo.trigger-meta.xml
│       ├── lwc/<bundle>/        LightningComponentBundle (strict directory name)
│       ├── aura/<bundle>/       AuraDefinitionBundle (strict directory name)
│       ├── objects/<Object>/    decomposed: fields/, recordTypes/, listViews/, ...
│       ├── flows/               Flow: Foo.flow-meta.xml
│       ├── permissionsets/      PermissionSet: Foo.permissionset-meta.xml
│       ├── layouts/             Layout: Object-Layout Name.layout-meta.xml
│       ├── flexipages/          FlexiPage: Foo.flexipage-meta.xml
│       ├── labels/              CustomLabels: CustomLabels.labels-meta.xml
│       ├── staticresources/     StaticResource: Foo.resource-meta.xml + payload
│       └── settings/            Settings: Case.settings-meta.xml
├── .vibeforce/                  vibe-force config + state + reports
├── .sf/  .sfdx/  .localdevserver/   CLI-generated; never edit, never commit
└── node_modules/                never commit

sfdx-project.json quick reference

PropertyRequiredPurpose
packageDirectoriesyesArray of { "path": "...", "default": true }. Relative paths only. Exactly one default; with a single entry it is implied
sourceApiVersionrecommendedAPI version the metadata conforms to — becomes <version> in the generated manifest. vibe-force sets 67.0
namespaceoptionalNamespace applied to scratch orgs created from this project; must be linked to the Dev Hub
sfdcLoginUrloptionalLogin URL for sf org login *; default https://login.salesforce.com. Use the My Domain form for sandboxes
oauthLocalPortoptionalOAuth callback port; default 1717. Must match the connected app callback URL
packageAliasesoptionalCLI-maintained map of readable names to 0Ho/04t package ids
pluginsoptionalPer-plugin settings you want version-controlled
replacementsoptionalDeploy-time string/regex substitution driven by env vars or files
sourceBehaviorOptionsoptional (Beta)Extra decomposed types. Never hand-edit — run sf project convert source-behavior
pushPackageDirectoriesSequentiallydeprecatedOnly affected the retired force:source:push; has no effect on sf project deploy start

Full property tables, packaging fields, and replacements variants: references/sfdx-project-schema.md.

Core patterns

1. Start the project, pin the API version

sf template generate project --name my-project --default-package-dir force-app \
  --manifest --api-version 67.0
cd my-project
{
  "packageDirectories": [{ "path": "force-app", "default": true }],
  "namespace": "",
  "sfdcLoginUrl": "https://login.salesforce.com",
  "sourceApiVersion": "67.0"
}

Keep sourceApiVersion equal to the vibe-force apiVersion (config/vibe-force.defaults.json) and to org-api-version; sf doctor runs a sourceApiVersion matches apiVersion check and warns when they diverge. sourceApiVersion governs the shape of the metadata: deploying a file that contains a field introduced in a later API version fails if sourceApiVersion is older, and retrieving with an older value silently drops newer subelements.

2. Model the domain with multiple package directories

{
  "packageDirectories": [
    { "path": "force-app", "default": true },
    { "path": "sales-ext" },
    { "path": "service-ext" },
    { "path": "unpackaged" }
  ],
  "sourceApiVersion": "67.0"
}

Each directory repeats the standard main/default/... tree, so MyObject__c can live in force-app while a new field on it lives in sales-ext/main/default/objects/MyObject__c/fields/. Facts that matter:

  • The split is client-side only. Deploying does not associate a directory with a package in the org.
  • sf project deploy start with no targeting flag deploys all package directories in one transaction. Order is not guaranteed, so sequence explicitly when you need it:
sf project deploy start --source-dir force-app --target-org vf-dev
sf project deploy start --source-dir sales-ext --source-dir service-ext --target-org vf-dev
  • The default directory is the retrieve target and the default output directory for conversions.
  • These directories are the natural boundary for later unlocked/2GP packages (skill sf-packaging-release), and the natural ownership boundary for the vibe-force wave-1 agents.

3. Know the source format rules that surprise people

RuleDetail
Custom objects and custom object translations are always decomposedobjects/<Object>/<Object>.object-meta.xml plus fields/, recordTypes/, listViews/, validationRules/, compactLayouts/, fieldSets/, businessProcesses/, indexes/, sharingReasons/, webLinks/
Optional decomposition is opt-in and betaCustomLabels, PermissionSet, SharingRules, Workflow, ExternalServiceRegistration via sf project convert source-behavior --behavior <value>
Bundles need exact directory nameslwc, aura, staticresources, experiences, documents, objects, objectTranslations, sites, emailservices, waveTemplates, lightningTypes, genAiFunctions, restrictionRules, bots, aiAuthoringBundles are strictDirectoryName types — a bundle in the wrong folder is invisible to the CLI
Static resources live only in <pkg>/main/default/staticresources.zip/.jar archives are auto-expanded on retrieve; an archive that exists as a single file stays a single file. New resources use their MIME extension (.gif, .png); legacy .resource files keep that extension
Content types have two filesApexClass, ApexTrigger, ApexPage, ApexComponent, EmailTemplate, StaticResource ship a payload plus -meta.xml. Treat them as a pair in .forceignore and in reviews
Names with special characters are URL-encoded on diskCustom: Marketing Profile becomes Custom%3A Marketing Profile.profile-meta.xml, and .forceignore must use the encoded name
Folder-based types nest one levelreports/<Folder>/<Report>.report-meta.xml, same for dashboards, documents, email

4. Write .forceignore deliberately

.forceignore uses .gitignore syntax and is honoured by project deploy start, project retrieve start, project convert source, and project delete source.

# Never deploy tooling and local artifacts
**/jsconfig.json
**/.eslintrc.json
**/__tests__/**
.sf/
.sfdx/
.localdevserver/

# Org-owned, human-managed metadata
**/profiles/**
**/*.settings-meta.xml
**/labels/CustomLabels.labels-meta.xml

# A specific component (MetadataWithContent needs both files, or a trailing *)
force-app/main/default/classes/LegacyBatch.cls*

# A whole bundle
**/lwc/legacyComponent

Precedence: when the commands decide whether to exclude a file they walk up from that file until they find the first .forceignore, consult it, and stop. A .forceignore inside sales-ext/ therefore fully overrides the project-root one for files under sales-ext/. Put the primary file at the project root; a per-package file is legitimate but must be documented.

Independently of .forceignore, the project commands always skip dot-files and dot-directories, files ending in .dup, package2-descriptor.json, and package2-manifest.json.

Verify, never guess:

sf project list ignored
sf project list ignored --source-dir force-app/main/default/classes
sf project deploy preview --target-org vf-dev     # shows Will Deploy / Will Delete / Conflicts / Ignored

vf-check static fails when a changed file is silently ignored by .forceignore but expected to be part of the story, which is the common cause of "it works in my scratch org but not in UAT".

5. Operate source tracking honestly

Org typeSource tracking
Scratch orgAlways available, on by default
Developer / Developer Pro sandboxAvailable when the production org has source tracking enabled
Developer Edition, production, Partial Copy, Full sandboxNot supported
sf project deploy preview  --target-org vf-dev     # local changes, deletions, conflicts, ignored
sf project retrieve preview --target-org vf-dev    # remote changes

sf project deploy start  --target-org vf-dev       # deploy exactly the changed, tracked source
sf project retrieve start --target-org vf-dev      # retrieve exactly the remote changes

sf org enable tracking  --target-org vf-sandbox
sf org disable tracking --target-org vf-sandbox
sf project reset tracking --target-org vf-dev --revision 12345
sf project delete tracking --target-org vf-dev     # forget all local tracking state

Conflict resolution: preview reports Conflicts [n]. Resolve the file, then overwrite one side deliberately and narrowly:

# Keep the local version of one class, then continue normally.
sf project deploy start --metadata ApexClass:WidgetClass --ignore-conflicts --target-org vf-dev
# Or keep the org version of that class.
sf project retrieve start --metadata ApexClass:WidgetClass --ignore-conflicts --target-org vf-dev
sf project deploy start --target-org vf-dev

--ignore-conflicts silently discards the other side's work. Scope it to the component you decided about; never add it to a whole-project command "to make the error go away". vibe-force hooks warn on --ignore-conflicts in standard mode and block it against a production alias in strict mode.

Deleting metadata needs sf project delete source, which removes it from the org and the local project; plain file deletion plus deploy start does not delete anything in the org unless the component is tracked as deleted.

sf project delete source --metadata ApexClass:ObsoleteService --target-org vf-dev --check-only
sf project delete source --metadata ApexClass:ObsoleteService --target-org vf-dev --no-prompt

Performance: tracking adds SourceMember queries and polling. A "medium" project is 30+ components or 50+ tests, "large" is 600+ components or 150+ tests; above that, deploy in smaller sets or tune SF_SOURCE_TRACKING_BATCH_SIZE (see skill sf-cli-operations).

6. Convert between formats when you inherit a repo

# Metadata format (retrieve output, change-set export) -> source format
sf project convert mdapi --root-dir ./mdapi-out --output-dir force-app

# Source format -> metadata format, for a tool that only speaks Metadata API
sf project convert source --source-dir force-app --output-dir mdapi-package --package-name MyApp

For project convert mdapi, the applicable .forceignore is the one in the metadata retrieve directory (next to package.xml), not the project root.

7. Keep .vibeforce/ and generated directories straight

PathCommit?Content
.vibeforce/config.jsonyesProject gates, org aliases, hook mode — reviewed like code
.vibeforce/state/ownership.jsonnoWave-1 path ownership for the current story
.vibeforce/state/deploy-jobs.jsonnoValidation job ids for quick deploy
.vibeforce/state/contract.mdoptionalCross-slice contract written in wave 0; commit it when it documents a decision worth keeping
.vibeforce/reports/*.jsonnoCheck-runner output
.sf/, .sfdx/noCLI internals, auth, local config — never edit by hand
.localdevserver/noLocal dev server cache (skill sf-local-development)
node_modules/, coverage/, *.lognoBuild artifacts

vf-init seeds a consumer project from this plugin's templates/ directory (project config seed, formatter and lint configuration, .forceignore/.gitignore fragments) and creates .vibeforce/; the exact inventory is whatever templates/ in the installed plugin contains.

Anti-patterns

Anti-patternConsequenceFix
Two "default": true package directoriesThe CLI cannot resolve the retrieve/convert targetExactly one default
Absolute paths in packageDirectoriesBreaks on every other machine and in CIRelative paths ("force-app", "./force-app")
Hand-editing sourceBehaviorOptionsProject claims decomposition that the files do not have; deploys failsf project convert source-behavior --behavior <value> (--dry-run first)
Deleting a metadata file and deployingComponent stays in the orgsf project delete source, or a destructiveChanges*.xml manifest
--ignore-conflicts on a whole-project deploySilently overwrites teammates' org changesResolve per component, then deploy
Committing .sf/ or .sfdx/Leaks auth material and machine-local configGitignore both; rotate anything already pushed
Profiles deployed from source alongside permission setsProfile XML is org-wide and merge-hostile; diffs are unreviewablePermission sets + permission set groups; ignore **/profiles/** (skill sf-security-model)
One giant force-app for a multi-team orgEvery story touches the same tree; ownership and packaging are impossiblePackage directory per domain (pattern 2)
sourceApiVersion left at whatever the project template shippedNewer subelements are dropped on retrieve and rejected on deployPin to the vibe-force apiVersion
.forceignore entry naming only Foo.clsFoo.cls-meta.xml still deploysFoo.cls* or list both files

Verification

# Project definition parses and resolves the way you think
node -e "const p=require('./sfdx-project.json');console.log(p.sourceApiVersion, p.packageDirectories)"
sf project list ignored
sf project deploy preview --target-org vf-dev
sf project retrieve preview --target-org vf-dev

# Manifest round-trip
sf project generate manifest --source-dir force-app --name manifest/package.xml
sf project deploy start --manifest manifest/package.xml --dry-run --target-org vf-dev

# vibe-force gates
node "$VF_ROOT/scripts/checks/vf-check.mjs" static          # format + lint + analyzer on changed files
node "$VF_ROOT/scripts/checks/vf-check.mjs" local           # static + jest
node "$VF_ROOT/scripts/checks/vf-check.mjs" deploy-validate --target-org vf-int

vf-check format and vf-check lint resolve files through packageDirectories, so a directory missing from sfdx-project.json is also invisible to the gates — a strong reason to keep the file accurate.

References

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.