Writing rules that are productive in a team is a discipline of its own. Here are the most important guidelines.
1. One Rule per File
.codecharter/rules/
├── no-manager-suffix.ccr
├── controller-must-end-controller.ccr
└── domain-must-not-reference-web.ccr
Not:
.codecharter/rules/
└── all-naming-rules.ccr # 200 lines, 12 rules inside
One @name, one severity, one responsibility per file. This keeps diffs
readable and suppressions targeted.
2. Meaningful File Names
The file name appears in the build log when the rule fires. rule-42.ccr helps
no one. controller-must-end-controller.ccr immediately tells you what's wrong.
Convention: kebab-case, verb phrase where possible.
3. Start Small with Severity info
Setting a new rule straight to error creates friction. Instead:
- Set the rule to
info. - Let it run for a week and see which locations it catches.
- Fix or suppress existing locations.
- Promote to
warn. - Once the team has established it as a convention:
error.
4. Write @recommendation Honestly
@recommendation "Add 'CancellationToken cancellationToken = default' as the last parameter"
Not:
@recommendation "Fix this"
The recommendation is what the developer sees in the VS Code hover and reads in the build log. If it's useless, the whole rule is useless — devs will disable it rather than follow it.
5. Don't Duplicate Roslyn
If Microsoft.CodeAnalysis.NetAnalyzers already covers a rule (CA1822,
CA2007, ...), checking it again with CodeCharter is just noise. Instead:
- Roslyn analyzers for language-level concerns
- CodeCharter for team-specific conventions and architecture
6. Keep Architecture Rules Specific
# Good, specific
@name "Domain layer must not reference Web layer"
from t in Types
where t.Namespace.StartsWith("Acme.Domain")
where t.UsedTypes.Any(u => u.Namespace.StartsWith("Acme.Web"))
select t
# Bad, too generic
@name "Layers must not be violated"
from t in Types
where t.UsedTypes.Any(u => true) # what?
select t
One rule per concrete architectural relationship. If you have five layers, write five rules — don't collapse them into one.
7. Keep an Eye on Performance
Sub-queries on large collections are expensive:
# Expensive when there are 5000 types
from t in Types
where t.UsedTypes.Any(u => u.Methods.Any(m => m.IsAsync))
select t
If you need something like this, check whether you can start from the outer collection directly:
# Direct
from m in Methods
where m.IsAsync
where m.DeclaringType.UsedByTypes.Any(...)
select m
8. Prefer SyntaxIssues When Available
Instead of:
from m in Methods
where m.CalledMethods.Any(c => c.FullName == "System.DateTime.UtcNow.get")
select m
Prefer:
SyntaxIssues.Where(s => s.Kind == "DateTimeDirectUsage")
SyntaxIssues are pre-analyzed and point directly to the location. Faster,
more readable, less fragile.
9. No Magic Strings for Namespaces
If you need to hard-code namespace prefixes, at least centralize them:
# In every rule:
where t.Namespace.StartsWith("Acme.Domain")
where u.Namespace.StartsWith("Acme.Web")
If you rename a namespace you'll have to update every file. There's no canonical solution for this today, but we're thinking about DSL constants for v2.
10. Suppressions with Justification
If you set require-justification = true, all suppressions must include a
comment. Recommendation: enable it. Suppressions without justification leak.
Anti-Patterns
- Rules that only have
select twithout anywhere. This produces one Finding per element in the collection, which is almost never useful. - Rules that match almost every file. Either the severity is wrong, or the rule doesn't fit the team.
- Rules that only fire on a single location. That might be a suppression pattern in reverse — if you wanted to flag one specific spot, you don't need a rule for that.
- Hundreds of suppressions on one rule. If 80% of findings are suppressed, the rule is wrong.
Reviews
Treat rule changes like code changes — put them through PR review. A new rule changes the behavior of the build system for everyone; that deserves a discussion.