# Custom Linters for AI Agent Guidance

Custom lint rules are one of the most effective harness engineering tools. Unlike
documentation that agents might misinterpret, linters provide **immediate,
unambiguous feedback** with **actionable remediation instructions**.

## Why Custom Linters Matter for Agents

1. **Agents read lint output.** When a linter fails, the agent sees the error
   message and can self-correct -- but only if the message tells it HOW to fix
   the violation.

2. **Linters enforce invariants.** Rules like "never import server code in client
   components" are hard to express in documentation but trivial to enforce with
   a lint rule.

3. **Linters scale.** You write the rule once and it catches every violation,
   across every agent session, forever.

## Key Principle: Remediation Instructions

The single most important thing about a custom lint rule is its **error message**.
A bad message:

```
Error: Invalid import detected.
```

A good message:

```
Error: Server-only module imported in client component.

  src/app/dashboard/page.tsx:3
  import { db } from '@/server/database'

  FIX: Server-only imports (from @/server/*) cannot be used in client
  components. Instead:
    1. Create a server action in src/server/actions/
    2. Call the server action from your client component
    3. See src/server/actions/get-invoices.ts for an example

  DOCS: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions
```

The good message includes:
- **What** is wrong
- **Where** it is (file, line)
- **How** to fix it (step-by-step)
- **Example** of the correct pattern
- **Link** to documentation

## Creating Custom Lint Rules

### ESLint (JavaScript/TypeScript)

Create rules in an ESLint plugin. Place custom rules in a local package:

```
linters/
  eslint-plugin-project/
    package.json
    src/
      rules/
        no-server-imports-in-client.ts
        require-workspace-scope.ts
        no-raw-sql.ts
      index.ts
```

Example rule skeleton (`no-server-imports-in-client.ts`):

```typescript
import { Rule } from 'eslint';

const rule: Rule.RuleModule = {
  meta: {
    type: 'problem',
    docs: {
      description: 'Disallow server-only imports in client components',
    },
    messages: {
      serverImportInClient: [
        'Server-only module "{{module}}" imported in client component.',
        '',
        'FIX: Move data fetching to a server action:',
        '  1. Create a server action in src/server/actions/',
        '  2. Export it with "use server" directive',
        '  3. Call it from your client component',
        '  4. Example: src/server/actions/get-invoices.ts',
      ].join('\n'),
    },
  },
  create(context) {
    // Rule implementation
    return {
      ImportDeclaration(node) {
        const source = node.source.value as string;
        const filename = context.getFilename();

        if (isClientComponent(filename) && isServerModule(source)) {
          context.report({
            node,
            messageId: 'serverImportInClient',
            data: { module: source },
          });
        }
      },
    };
  },
};
```

### Roslyn analyzers (C# / .NET)

Write a custom Roslyn analyzer + code-fix in a class library targeting
`Microsoft.CodeAnalysis.CSharp.Workspaces`. Distribute it as an analyzer
NuGet package (`*.Analyzers.csproj`) and reference it from the consuming
project so the rule runs on every `dotnet build`.

```
linters/
  Project.Analyzers/
    Project.Analyzers.csproj
    DiagnosticIds.cs
    NoDirectDbContextInControllers/
      NoDirectDbContextInControllersAnalyzer.cs
      NoDirectDbContextInControllersCodeFix.cs
    RequireTenantScopeOnQueries/
      RequireTenantScopeOnQueriesAnalyzer.cs
  Project.Analyzers.Tests/
    Project.Analyzers.Tests.csproj
```

Example analyzer skeleton (`NoDirectDbContextInControllersAnalyzer.cs`):

```csharp
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class NoDirectDbContextInControllersAnalyzer : DiagnosticAnalyzer
{
    public const string DiagnosticId = "PRJ001";

    private static readonly DiagnosticDescriptor Rule = new(
        id: DiagnosticId,
        title: "Controllers must not depend on DbContext directly",
        messageFormat: "Controller '{0}' injects '{1}' directly. " +
            "Inject an application-layer service (MediatR handler or use case) instead.\n" +
            "FIX:\n" +
            "  1. Move the data access into a handler in src/Project.Application/.\n" +
            "  2. Inject IMediator (or the handler interface) into the controller.\n" +
            "  3. Example: src/Project.Web/Controllers/InvoicesController.cs.",
        category: "Architecture",
        defaultSeverity: DiagnosticSeverity.Error,
        isEnabledByDefault: true,
        helpLinkUri: "https://internal-docs/architecture/controllers.html");

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
        ImmutableArray.Create(Rule);

    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();
        context.RegisterSyntaxNodeAction(AnalyzeConstructor, SyntaxKind.ConstructorDeclaration);
    }

    private static void AnalyzeConstructor(SyntaxNodeAnalysisContext context)
    {
        // Implementation: walk constructor parameters of any class deriving from
        // ControllerBase, flag DbContext-derived parameter types, and report with
        // the actionable message above.
    }
}
```

Wire the rule into the build by referencing the analyzer project as an analyzer:

```xml
<ItemGroup>
  <ProjectReference Include="..\..\linters\Project.Analyzers\Project.Analyzers.csproj"
                    OutputItemType="Analyzer"
                    ReferenceOutputAssembly="false" />
</ItemGroup>
```

Pair the analyzer with `.editorconfig` rules (e.g.
`dotnet_diagnostic.PRJ001.severity = error`) so severity is checked into source.

### PSScriptAnalyzer (PowerShell)

For PowerShell modules and scripts, ship a custom rule module loaded by
`PSScriptAnalyzerSettings.psd1`:

```powershell
# linters/PSRules/Measure-NoPlainTextSecret.psm1
function Measure-NoPlainTextSecret {
    [CmdletBinding()]
    [OutputType([Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord[]])]
    param ([System.Management.Automation.Language.ScriptBlockAst]$ScriptBlockAst)

    # Walk the AST; if a string literal looks like a secret AND is assigned to a
    # parameter that ultimately reaches an outbound call, emit a diagnostic with:
    #   "PRJ010 Plain-text secret detected. Use Get-Secret from the
    #    SecretManagement module instead. FIX: store the value in Azure Key Vault
    #    via the Az.KeyVault module, register a vault, and resolve at runtime."
}

Export-ModuleMember -Function Measure-NoPlainTextSecret
```

Reference it from `PSScriptAnalyzerSettings.psd1`:

```powershell
@{
    CustomRulePath      = @('linters/PSRules')
    IncludeDefaultRules = $true
    Severity            = @('Error', 'Warning')
}
```

## Integrating with Agent Workflows

### In Pre-Commit Hooks

```bash
#!/bin/bash
# .git/hooks/pre-commit or via pre-commit framework

# Run custom linters with verbose output so agents see remediation
eslint --rule 'project/no-server-imports-in-client: error' --format stylish .
```

### In CI

```yaml
# .github/workflows/lint.yml
- name: Custom lint rules
  run: |
    pnpm lint:custom --format=verbose
    # Verbose format includes remediation instructions
```

### In .github/copilot-instructions.md / AGENTS.md

```markdown
## Lint Commands
pnpm lint           # Standard ESLint + custom project rules
pnpm lint:custom    # Only custom project rules (faster)

If a custom lint rule fails, read the full error message -- it contains
step-by-step fix instructions and an example of the correct pattern.
```

## Rule Ideas for Common Projects

| Rule Name                    | Purpose                                    |
|-----------------------------|--------------------------------------------|
| `no-server-imports-in-client` | Prevent bundling server code in client     |
| `require-workspace-scope`    | Enforce multi-tenant data isolation        |
| `no-raw-sql`                | Force use of query builder / ORM           |
| `require-error-handling`    | Ensure all async calls handle errors       |
| `no-internal-ids-in-api`    | Prevent leaking internal DB IDs            |
| `require-test-for-export`   | Every exported function needs a test       |
| `no-console-log`            | Use structured logger, not console.log     |
| `max-function-complexity`   | Keep functions simple and testable         |
| `require-jsdoc-public`      | All public APIs must be documented         |
| `no-cross-module-imports`   | Enforce module boundaries                  |

## Checklist for Writing a New Rule

- [ ] Rule name clearly describes what it checks
- [ ] Error message explains WHAT is wrong
- [ ] Error message explains HOW to fix it (step-by-step)
- [ ] Error message includes an EXAMPLE of correct code or a file path to one
- [ ] Error message links to DOCUMENTATION (internal or external)
- [ ] Rule has unit tests for both violations and valid code
- [ ] Rule is registered in the linter configuration
- [ ] Rule is documented in .github/copilot-instructions.md / AGENTS.md with its purpose
