Coding standards
The canonical coding-standards doc — assembled from the rules already in force across
.github/copilot-instructions.md,CLAUDE.md,CONTEXT.md, the ADRs, and the actual code; previously-open points (docstrings, formatting/linting, the commands-layer type-checking split) have their decision record in Decide: coding standards for the repo.This doc is the single source reviewers (the
code-reviewskill, CodeRabbit's path instructions,.github/copilot-instructions.md) derive from — they should shrink to a pointer here rather than repeat these rules.
Architecture & layering
crm/core/*— Web API logic, one module per domain (entity,connection,metadata,async_ops,batch,charts,dashboard,dup, …).crm/commands/*— thin Click wrappers, one file percrm <group>, one command group per domain, mirroring thecoremodule it wraps.- Credentials come only from a saved profile (
crm profile add) or a per-run--password; never environment variables.
Type checking
pyrightconfig.jsonsetsstrictglobally;crm/commands/*and most test files opt back out to basic via a# pyright: basicheader comment. This split is permanent by design, not debt: the commands layer is thin, decorator-heavy Click glue where strict mode fights the framework for near-zero bug yield. Don't flag it and don't tighten files opportunistically — when a command file accumulates real logic, the fix is moving that logic intocrm/core/*(the strict surface, together withcrm/utils/d365_backend.py), not tightening the wrapper.- Boundary rule for D365 payloads:
dict[str, Any]at the raw OData seam (aTypedDictcast over external JSON is a false contract — the server can add/omit keys the type doesn't know about). ReserveTypedDictfor structures the CLI itself constructs and owns (e.g.Referenceincrm/core/references.py). - Run pyright locally with
--pythonpath .venv/bin/python --pythonversion 3.13— omitting either flag masks import errors or lets 3.10+-only symbols pass that fail at the pinned runtime.
Docstrings
- Intent rule, not a coverage quota: a public function in
crm/core/*gets a docstring when its name and signature don't already say what it does and why. Self-explanatory helpers stay bare — forced docstrings are narrative noise. - Sectioned docstrings use Google style (
Args:/Returns:/Raises:) — the only style in use today; don't introduce Sphinx/NumPy variants. - Lint enforces format only (ruff
D2xx/D4xx: quoting, blank lines, imperative mood) on docstrings that exist; there is deliberately no missing-docstring (D1xx) rule.
Formatting & linting
Decision record: Decide: coding standards for the repo;
gates landed via Task: adopt ruff — CI enforces
ruff check + ruff format --check, and pre-commit install (once per clone) runs both on
staged files so the format round-trip never reaches CI.
- ruff is the single formatter and linter.
pyproject.tomlis the source of truth for the exact rule codes; the agreed shape:- line length 100 (matches the codebase's natural style; not the ruff default 88),
- rule families
E,W,F,I(import order),B(bugbear),UP(pyupgrade,target-version = py313), plus the format-onlyDsubset above, - deliberately excluded:
S(bandit — rejected in the tooling sweep),C90complexity,ANN(pyright owns typing),D1xxdocstring coverage, andD205(blank line between docstring summary and description) — D205 treats only physical line 1 as the summary, so it forces multi-line summaries onto one line, which then tripsE501as breakable prose; 482 of 569 sites can't satisfy both without rewording, and the descriptive-first-line convention here is deliberate (#843).
ruff formatcovers the whole tree —crm/including tests, plusscripts/— no carve-outs.- Noisy rules in tests get a scoped
per-file-ignoresentry, never a global rule removal. - Semgrep CE enforces house-convention rules ruff structurally can't express (see Dry-run
contract below). It is not in the
[dev]extras — its engine needs Python ≥3.10, so it runs in its own venv in CI and is installed separately locally:pipx install semgrep(oruvx semgrep scan --config ci/semgrep-rules.yml --error --metrics off). Custom rules only; no registry rulesets (they re-tread ruff/CodeQL). Decision: tooling sweep on #831 / adopted in #849.
More tools landed in the tooling sweep (#848):
- actionlint lints workflow YAML and the inline bash in
run:blocks (shellcheck-backed). It runs both as a pre-commit hook and as a step in CI'slintjob. The pre-commit hook fires only on.github/workflows/**changes, so a Go toolchain is needed locally only when editing a workflow; CI runs the pinned release binary directly. The rev is pinned in both.pre-commit-config.yamlandci.yml— bump together. - zizmor audits the workflows for security issues (credential persistence, cache poisoning,
excessive permissions, unpinned actions). It runs both as a pre-commit hook and as a step in CI's
lintjob (its own pinned venv, like semgrep), version in lockstep across both. Config is.github/zizmor.yml: a scopedunpinned-usespolicy allows ref/tag pins only for the first-partyactions/*org (the repo's deliberate tag-pin convention, no dependabot per #819) while third-party actions still require a full SHA pin; every other audit stays armed. Adopted in #858. - codespell is a typo checker wired as a pre-commit hook only — deliberately not a CI gate,
because a jargon false positive must never block a merge. Its D365 ignore-words list and skip
paths live in
[tool.codespell](pyproject.toml); the hook'sexcludemirrors thatskip(pre-commit passes explicit paths, which bypass codespell's own traversal-level skip). - Coverage is report-only (map #819): CI runs
pytest --covand uploads an XML + HTML artifact with no threshold gate and no Codecov.--covstays out of pytestaddoptsso localpytestruns stay fast;pytest-covis in the[dev]extra.
Click command pattern
Every command follows the same shape: @pass_ctx decorator, ctx: CLIContext as the first
parameter, ctx.backend() to reach the backend, ctx.json_mode / ctx.dry_run to branch
behavior, ctx.emit(...) as the single result seam.
@org_group.command("brief")
@pass_ctx
def org_brief(ctx: CLIContext):
with d365_errors(ctx):
brief = org_mod.org_brief(ctx.backend())
ctx.emit(True, data=brief)
House rules on top of the shape:
- Wrap file read/write in
try/except OSErrorand return a clean error envelope — never let a rawOSErrortraceback reach the user. - Mutually exclusive flags raise
click.UsageError(exit2); never fake it withctx.emit(False)(exit1) — those are different failure classes (see Exit codes below). Validate untrusted input before callingctx.backend(), not after. CLIContext.emit(meta=...)renders in human mode too, not just--json— gate any JSON-only field onctx.json_modebefore adding it tometa.- When serializing a Click option for introspection (
crm describe, docs generation), emitopt.secondary_opts(the--no-*forms) alongsideopt.opts, notoptsalone. crm.cli.cliis a lazy group (_LazyJsonAwareGroup): subcommands are not loaded until requested, so a naive.commandstree walk sees 0 leaves. Any tooling/gate that enumerates the command tree must walk vialist_commands(ctx)+get_command(ctx, name)and assert a sanity floor (e.g.len(leaves) > 100) so a lazy-loading regression can't silently empty the set — the e2e coverage gate would otherwise pass while testing nothing.
Error handling & exit codes
- All D365/backend failures are a
D365Error(crm/utils/d365_backend.py) carryingstatus,code,response_body, and (for multi-stage writes)completed_steps/stage. Commands funnel it through thed365_errors(ctx)context manager (crm/commands/_helpers/errors.py), never a baretry/except D365Errorthat hand-builds the envelope. - Exit-code contract (ADR 0001):
0success,1operational failure (server error, in-command validation, declined confirmation),2Click usage error. Granular per-class codes are rejected — failure-class detail lives in the envelope (error,meta.status/code/category/retryable), not the exit code. - The envelope's
{status, code, category, retryable}is reserved and always derived from the caughtD365Erroritself; anenrich(exc)callback'sextra_metais strictly additive and raises if it names a reserved key. - Never
assertfor a runtime invariant in shipped code —assertis stripped underpython -O, and the frozen PyInstaller build can run optimized. RaiseD365Error(or the appropriate domain error) even for a logically-unreachable check. Enforced by ruffS101(the one bandit rule selected);crm/tests/**,evals/**andscripts/**are exempt — they never ship, and pytest's assertion model is built onassert.
Output contract
- The
datapayload is curated and CLI-owned, never a passthrough of the raw D365 Web API response (ADR 0008). List verbs put a bare array indata; OData paging (@odata.nextLink,@odata.count) relocates tometa.next_link/meta.count; per-row@odata.*protocol keys are stripped. _entity_id(+_entity_id_url) is the one normalized key for an affected record's GUID acrosscreate/update/delete/entity get— the leading underscore marks it as CLI-synthesized, distinct from the real PK attribute.- Client-side output shaping (
--fields,--jq, ADR 0023) is a post-curation, envelope-preserving transform applied once at theCLIContext.emitseam — never re-implemented per command. It only touchesdata; error envelopes bypass shaping entirely.
Dry-run contract
- Reads-execute rule: under
--dry-run, only mutations are previewed — every GET still runs for real, which is what lets a preview report live facts (_exists,would_skip) instead of guesses. - A dry-run mutator returns
{_dry_run: true, would_*}(plusmeta.dry_run: true), never the bare success key a real write would return (deleted: true, etc.). - Machine-checked by a Semgrep CE rule (
ci/semgrep-rules.yml, CIlintjob): abackend.dry_runshort-circuit that directly returns a dict literal missing_dry_runfails the build. The rule catches the direct-return shape only — a preview built into a local variable then returned stays with LLM/human review (documented in the rule header). Iterate new house-convention rules the same way: zero false positives on conformingcrm/core/*is the bar before a rule ships.
Encoding
Full policy in docs/contributing/encoding.md; the load-bearing rules:
- Every file read uses an explicit encoding — never the OS-locale default.
utf-8-sigfor anything a user or external tool may author (CSV, JSONL, YAML/JSON specs, FetchXML); plainutf-8(no BOM) for writes. - Subprocess captures pin
encoding="utf-8", errors="replace"—text=Truealone decodes with the locale default and can crash on a stray non-UTF-8 byte. - Unix-only imports (
fcntl,pwd,termios, …) needtry/except ImportErrorat the import line so the module still loads on Windows. - Consult metadata to type a value crossing the file boundary; don't infer type from the value's shape (a string-typed alternate-key column keeps its verbatim text, leading zeros and all).
D365 API conventions
- Logic enforcing a header invariant ("never emit both X and Y") uses
requests'CaseInsensitiveDict, never a plaindict— headers are case-insensitive on the wire. @odata.bindnavigation-property names must match$metadatacasing exactly; system entities (sdkmessage*,solution, …) use lowercase logical names — flag a guessed PascalCase name.
Docs-in-sync obligation
Every new/changed command, flag, or behavior ships its docs in the same change:
docs/how-to/<group>.md, docs/reference/cli.md, README.md if user-facing, and crm/skills/
if the change is workflow-visible. Never hand-edit CHANGELOG.md — python-semantic-release
generates it from commit subjects. A new lazily-loaded command group is added to crm.spec
hiddenimports (PyInstaller) in the same change.
Public-repo hygiene
The repo is public and must stay generic: flag real org names, internal hostnames, or
real-looking GUIDs in tests/docs. Placeholders are Contoso / internalcrm.contoso.local.
Known non-issues — do not flag
D365Backendretries only502/503/504(+429) for idempotent methods, not all 5xx; a test using a bare500to exercise the no-retry path is intentional.- Click is pinned
>=8.2:CliRunnerhas nomix_stderrparameter — stdout/stderr are always separate streams. # pyright: basicheaders in test files are intentional (see Type checking above).- OAuth token acquisition raises
D365Errorduringsession.get(), not arequestsexception — a caller catchingD365Erroraround a raw session call is correct, not dead code.