spaarke-conventions

Enforces Spaarke-specific coding conventions when generating or reviewing code. This skill is always applied to ensure consistency across the codebase. It codifies the standards from root CLAUDE.md in

SKILL.md

GitHub

spaarke-conventions

Last Reviewed: 2026-05-16 Reviewed By: ai-procedure-quality-r1 (Phase 2b Wave 2b-A) Exemplar rationale: Conventions evolve with the tech stack (Fluent UI v9 supplanted v8; React 18 in Code Pages but 16 in PCF; Redis-first caching per ADR-009). A single frozen exemplar would lag the rules. The conventions themselves are the canonical reference. Drift-prevention note: This skill codifies rules from root CLAUDE.md Coding Standards section. When CLAUDE.md changes, this skill must update in lockstep. Find-SkillReferenceDrift.ps1 (Phase 4a) will catch missed propagation.

Purpose

Enforces Spaarke-specific coding conventions when generating or reviewing code. This skill is always applied to ensure consistency across the codebase. It codifies the standards from root CLAUDE.md into actionable rules.

Always-Apply Rules

These conventions apply automatically to ALL code generation and review.

For Fluent UI v9 work specifically (any .tsx under Spaarke.UI.Components/, code-pages/, external-spa/, office-addins/, or pcf/**/components/): also invoke fluent-v9-component skill — it loads the curated patterns (component authoring / theming / portal-gotcha / React-version boundaries) that complement these naming + structure rules.

File Naming

File Type Convention Example
C# class files PascalCase AuthorizationService.cs
C# interface files I + PascalCase IAuthorizationService.cs
TypeScript components PascalCase DataGrid.tsx
TypeScript utilities camelCase formatters.ts
Test files {ClassName}.Tests.cs AuthorizationService.Tests.cs
ADRs ADR-{NNN}-{slug}.md ADR-001-minimal-api-and-workers.md
Config files kebab-case + .local.json azure-config.local.json

C# Naming Conventions

// Types: PascalCase
public class DocumentService { }
public interface IDocumentService { }
public record DocumentDto { }
public enum DocumentStatus { }

// Methods: PascalCase
public async Task<Document> GetDocumentAsync(string id) { }

// Properties: PascalCase
public string DocumentId { get; set; }

// Private fields: _camelCase
private readonly GraphServiceClient _graphClient;
private int _retryCount;

// Parameters & locals: camelCase
public void Process(string documentId)
{
    var localResult = DoWork();
}

// Constants: PascalCase
public const int MaxRetryAttempts = 3;
public static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(30);

TypeScript/React Naming Conventions

// Components: PascalCase (file and export)
// File: DataGrid.tsx
export const DataGrid: React.FC<IDataGridProps> = (props) => { }

// Interfaces: IPascalCase
interface IDataGridProps {
  items: IDataItem[];
  onSelect: (item: IDataItem) => void;
}

// Types: PascalCase (no I prefix)
type ButtonVariant = 'primary' | 'secondary';

// Functions: camelCase
function formatDate(date: Date): string { }
const calculateTotal = (items: number[]) => items.reduce((a, b) => a + b, 0);

// Variables: camelCase
const selectedItems: IDataItem[] = [];
let isLoading = false;

// Constants: SCREAMING_SNAKE_CASE (module-level) or camelCase (local)
const API_BASE_URL = '/api/v1';
const maxRetries = 3;

Directory Structure

src/
├── client/                    # Frontend code
│   ├── pcf/                   # PCF Controls
│   │   └── {ControlName}/     # One folder per control
│   │       ├── {ControlName}.tsx
│   │       ├── {ControlName}.css
│   │       └── index.ts
│   └── shared/                # Shared UI components
│       └── components/
├── server/                    # Backend code
│   ├── api/                   # Minimal API project
│   │   ├── Api/               # Endpoint groups
│   │   │   └── {Domain}Endpoints.cs
│   │   ├── Services/          # Business logic
│   │   ├── Infrastructure/    # External integrations (Graph, etc.)
│   │   └── Models/            # DTOs, domain models
│   └── shared/                # Shared .NET libraries
└── solutions/                 # Dataverse solution projects

tests/
├── unit/                      # Unit tests mirror src/ structure
├── integration/               # Integration tests
└── e2e/                       # End-to-end tests

Technology-Specific Patterns

.NET 8 Minimal API

// ✅ DO: Use endpoint groups
public static class DocumentEndpoints
{
    public static void MapDocumentEndpoints(this WebApplication app)
    {
        var group = app.MapGroup("/api/documents")
            .WithTags("Documents");
        
        group.MapGet("/", GetAllDocuments);
        group.MapGet("/{id}", GetDocument);
        group.MapPost("/", CreateDocument);
    }
    
    private static async Task<Results<Ok<DocumentDto>, NotFound>> GetDocument(
        string id,
        DocumentService service)
    {
        var doc = await service.GetByIdAsync(id);
        return doc is null ? TypedResults.NotFound() : TypedResults.Ok(doc);
    }
}

// ✅ DO: Use endpoint filters for auth (ADR-008)
group.MapGet("/{id}", GetDocument)
    .AddEndpointFilter<DocumentAuthorizationFilter>();

// ❌ DON'T: Use global middleware for resource auth
app.UseMiddleware<AuthorizationMiddleware>();

// ✅ DO: Use concrete types unless seam needed (ADR-010)
services.AddSingleton<SpeFileStore>();

// ❌ DON'T: Create interfaces for single implementations
services.AddSingleton<ISpeFileStore, SpeFileStore>(); // unnecessary

TypeScript/PCF Controls

// ✅ DO: Import from Fluent UI v9
import { Button, Input, DataGrid } from "@fluentui/react-components";

// ❌ DON'T: Import from Fluent UI v8
import { PrimaryButton } from "@fluentui/react"; // WRONG

// ✅ DO: Import from shared library (ADR-012)
import { DataGrid, StatusBadge } from "@spaarke/ui-components";

// ✅ DO: Use proper TypeScript types
interface IProps {
  items: IDataItem[];
  onSelect: (item: IDataItem) => void;
}

// ❌ DON'T: Use any type
const handleClick = (e: any) => { }; // WRONG

// ✅ DO: Use React.FC with props interface
export const MyComponent: React.FC<IProps> = ({ items, onSelect }) => {
  return <div>...</div>;
};

Auth v2 client contract (ADR-028)

// ✅ DO: Bootstrap once, consume via useAuth() or authenticatedFetch
import { initAuth, useAuth, authenticatedFetch } from '@spaarke/auth';

// Top-level (main.tsx / index.tsx) — call ONCE before render
await initAuth({ clientId, tenantId, bffBaseUrl, bffApiScope });

// In React components — use the hook
const { getAccessToken } = useAuth();

// For BFF calls — use authenticatedFetch (handles bearer + 401 retry automatically)
const response = await authenticatedFetch('/api/...', { method: 'POST', body });

// ❌ DON'T: Raw fetch with manual Authorization header
const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); // WRONG

// ❌ DON'T: Pass accessToken as a typed prop or constructor arg
<MyComponent accessToken={token} />  // WRONG (use authenticatedFetch)
new ApiClient(baseUrl, getAccessToken)  // WRONG (factory should accept authenticatedFetch)

// ❌ DON'T: Instantiate PublicClientApplication directly outside @spaarke/auth
const msal = new PublicClientApplication(config);  // WRONG (violates INV-7)

// ❌ DON'T: Reference retired token-transport symbols
window.__SPAARKE_BFF_TOKEN__  // WRONG — deleted in Phase A
import { tokenBridge } from '...';  // WRONG — deleted in Phase A
new BridgeStrategy() / new XrmStrategy() / new MsalSilentStrategy()  // WRONG — deleted in Phase A

Canonical reference: .claude/adr/ADR-028-spaarke-auth-architecture.md, .claude/patterns/auth/spaarke-sso-binding.md, .claude/constraints/auth.md.

Dataverse Plugins → NOT USED; Record Invariants Live on the Server (ADR-002, 2026-09-25)

Spaarke ships no Dataverse plugins (C#, plugin-backed Custom API, low-code). A rule a record must satisfy when saved (default, stamp, isolation, derived field) has one owner in the BFF write path; clients may preview it, never solely enforce it. See .claude/constraints/plugins.md.

// ❌ DON'T: enforce an invariant only in the wizard, then write via Xrm.WebApi
const payload = await applyFieldMappings(...);          // client-only enforcement (WP-2 gap)
await xrmDataService.createRecord('sprk_matter', payload);

// ✅ DO: the BFF create endpoint applies invariants inline and returns the record (WP-1/WP-3/WP-4)
const created = await authenticatedFetch(`${bff}/api/.../matters`, { method: 'POST', body: JSON.stringify(input) });
// optional: the wizard may PREVIEW mapped values before submit — the server result is authoritative
// ❌ DON'T: public class AnythingPlugin : IPlugin { ... }   — prohibited; escalate via CLAUDE.md §6.5 if you think you need one

Error Handling Patterns

API Responses

// ✅ DO: Use ProblemDetails for errors
app.MapGet("/api/documents/{id}", async (string id, DocumentService service) =>
{
    try
    {
        var doc = await service.GetByIdAsync(id);
        return doc is null 
            ? Results.Problem(
                statusCode: 404,
                title: "Document not found",
                detail: $"No document exists with ID '{id}'")
            : Results.Ok(doc);
    }
    catch (UnauthorizedAccessException)
    {
        return Results.Problem(
            statusCode: 403,
            title: "Access denied",
            detail: "You do not have permission to access this document");
    }
});

// ❌ DON'T: Return raw exceptions
catch (Exception ex)
{
    return Results.BadRequest(ex.Message); // Leaks internal details
}

PCF Controls

// ✅ DO: Show user-friendly errors
try {
  const data = await fetchData();
  setItems(data);
} catch (error) {
  setError("Unable to load documents. Please try again.");
  console.error("Fetch failed:", error); // Log for debugging
}

// ❌ DON'T: Show technical errors to users
catch (error) {
  setError(error.stack); // WRONG - users don't need stack traces
}

Async Patterns

// ✅ DO: Use async/await properly
public async Task<Document> GetDocumentAsync(string id)
{
    return await _graphClient.GetAsync(id);
}

// ❌ DON'T: Block on async (sync-over-async)
public Document GetDocument(string id)
{
    return _graphClient.GetAsync(id).Result; // WRONG - can deadlock
}

// ✅ DO: Use ConfigureAwait(false) in library code
public async Task<Document> GetDocumentAsync(string id)
{
    return await _graphClient.GetAsync(id).ConfigureAwait(false);
}

// ✅ DO: Return Task directly when no await needed
public Task<Document> GetDocumentAsync(string id)
{
    return _graphClient.GetAsync(id); // No await, just pass through
}

Documentation Standards

C# XML Documentation

/// <summary>
/// Retrieves a document by its unique identifier.
/// </summary>
/// <param name="id">The document's unique identifier.</param>
/// <returns>The document if found; otherwise, null.</returns>
/// <exception cref="UnauthorizedAccessException">
/// Thrown when the user doesn't have access to the document.
/// </exception>
public async Task<Document?> GetDocumentAsync(string id)

TypeScript JSDoc

/**
 * Formats a date for display in the UI.
 * @param date - The date to format
 * @param locale - Optional locale string (default: 'en-US')
 * @returns Formatted date string
 */
function formatDate(date: Date, locale = 'en-US'): string {
  return date.toLocaleDateString(locale);
}

Quick Validation Rules

When generating or reviewing code, automatically check:

Rule Check Severity
File naming Matches conventions above Warning
Import source Fluent UI v9, not v8 Warning
Interface necessity Single implementation? Info
Async pattern No .Result or .Wait() Warning
Error handling Uses ProblemDetails Warning
Dataverse plugin None exist — any IPlugin / plugin project is a violation (ADR-002) Error
Record invariant Enforced in BFF write path, not only in client wizard (ADR-002 WP-2) Warning
Type safety No any without justification Warning

Resources

  • Root CLAUDE.md - Full coding standards reference
  • docs/architecture/ - Architecture decisions and constraints
  • docs/adr/ - Decision rationale (if needed)

Related Skills

  • code-review: Uses these conventions for review checklist
  • adr-check: Deeper architectural validation
  • project-init: Ensures new projects follow structure

Failure Modes & Recovery

Failure Cause Prevention / Recovery
New code mixes Fluent UI v8 and v9 imports Author copied snippet from older code or external example v8 imports are an instant ADR-021 violation. Use import { ... } from "@fluentui/react-components" (v9) exclusively. /adr-check catches this.
PCF control attempts createRoot() from React 18 Author treated PCF like a Code Page — they're different per ADR-022 PCF (field-bound) uses React 16 APIs from ComponentFramework.ReactControl. Code Pages (standalone dialogs) use React 18 createRoot. NEVER mix. The error you'll see: createRoot is not a function.
BFF API uses global authorization middleware (app.UseMiddleware<AuthorizationMiddleware>()) Author followed generic ASP.NET tutorial Per ADR-008: use endpoint filters (AddEndpointFilter<DocumentAuthorizationFilter>()), never global middleware for resource authorization.
Code injects GraphServiceClient directly into controller Author bypassed the SpeFileStore facade Per ADR-007: never let Graph SDK types leak above SpeFileStore. Inject the facade, not the underlying client.
New webresource (.js or .html) created instead of PCF/Code Page Author followed legacy Dataverse customization pattern Per ADR-006: field-bound controls → PCF (pcf-deploy); standalone dialogs → React Code Pages (code-page-deploy). No new legacy JS webresources.
Project-scoped CLAUDE.md and root CLAUDE.md disagree on a convention Both updated separately; drift introduced Root CLAUDE.md is canonical for cross-cutting standards. Project CLAUDE.md may NARROW (add stricter rules) but not RELAX or CONTRADICT.
Raw fetch(url, { headers: { Authorization: 'Bearer ...' } }) or window.__SPAARKE_BFF_TOKEN__ / tokenBridge regression Author copied snippet from pre-v2 code or external tutorial Per ADR-028 (Spaarke Auth v2): use authenticatedFetch from @spaarke/auth. Tokens are managed by useAuth() hook. Never snapshot. /adr-check catches raw fetch patterns and retired symbols.
Per-PCF MsalAuthProvider.ts / direct new PublicClientApplication(...) Author bypassed @spaarke/auth singleton Per ADR-028 INV-7: all consumers share ONE PCA via @spaarke/auth. The only remaining pre-v2 PCF is UniversalQuickCreate (V3 cleanup target — do NOT pattern new PCFs on it).