honest-check
Does this structure look honest before it runs?
def handle_role(manifest):
if manifest["role"] == "admin":
return admin_handler(manifest)
elif manifest["role"] == "editor":
return editor_handler(manifest)
elif manifest["role"] == "viewer":
return viewer_handler(manifest)
# HC-P001: if/elif dispatch chain.
# You find this in code review — or you don't.
# No tool catches it until it's in production.
# honest-check catches it before you commit:
$ honest-check src/
src/handlers.py:4:5: error HC-P001
if/elif/else chain dispatches on value.
Use dict lookup. See honest-code-principles.md §1.
Found 1 error.
# CLI, LSP inline diagnostics,
# and framework startup — same rules, all three.
class OrderPipeline
def initialize
@status = "pending" # HC-P007: instance state
@items = [] # HC-P002: mutating method
end
def add_item(item)
@items << item # mutates self
end
end
# No tool tells you this is dishonest.
# Tests require mocks. Bugs require a debugger.
$ honest-check app/pipelines/
app/pipelines/order.rb:3:5: warning HC-P007
Instance state '@status'. Pass as parameter.
app/pipelines/order.rb:8:5: error HC-P002
Method 'add_item' mutates self.
Use TypedHash + pure function.
Found 1 error, 1 warning.
# Caught before commit. Not in production.
func HandleFormat(manifest Manifest) Manifest {
// I/O inside a non-boundary function
conn, _ := db.Connect(config.DSN)
rows, _ := conn.Query("SELECT ...")
// HC-P004: I/O inside non-boundary function.
// Breaks purity. Breaks testability.
// No tool catches this. Tests need mocks.
}
$ honest-check ./...
pipelines/format.go:8:5: error HC-P004
I/O inside non-boundary function 'HandleFormat'.
Add boundary=true if I/O is intentional,
or move I/O to a boundary link.
Found 1 error.
# Caught at the CLI, in the LSP as you type,
# and at framework startup in development.
// Chain type mismatch — found in production
class FormatChain {
protected array $pipes = [
ValidateInput::class, // emits: validated
FormatCurrency::class, // accepts: formatted — WRONG
// HC-P002: accepts types not provided by previous link.
// ValidateInput emits 'validated'.
// FormatCurrency expects 'formatted'.
// This KeyError surfaces at runtime.
];
}
$ honest-check app/
app/Chains/FormatChain.php:6:9: error HC002
Link 'FormatCurrency' accepts types not provided
by previous link 'ValidateInput': {formatted}.
Found 1 error.
# Chain type mismatch caught statically.
# Never reaches production.
# Structural violations surface at runtime or in code review.
# Dispatch chains, I/O in business logic, inheritance,
# lifecycle hooks — no tool catches them automatically.
# You find them in the debugger or in the incident report.
$ honest-check src/
# HC-P001: if/elif dispatch chain → use dict lookup
# HC-P002: mutating method → use typed dict + pure fn
# HC-P003: inheritance → use composition
# HC-P004: I/O in non-boundary fn → move to boundary
# HC002: chain type mismatch → fix the link interface
# Caught at the CLI, in the editor, at startup.
# Never in production.
The concept
You refactor a chain. Link A now emits a manifest with a sort field that can be "name", "date", or "status". Link B downstream expects sort to be "asc" or "desc". The values are incompatible. Nothing catches this until a request hits that path in production.
honest-check catches it before the code deploys. It reads your vocabulary declarations, traces the values flowing between links, and reports the mismatch. No type annotations required. No test run needed. It reads the same vocab dicts that the runtime reads.
It is a linter. It reads source files, walks the code structure, and reports violations. It never runs your application code. It never modifies files. The distinction from honest-test: honest-check catches structural problems by reading code. honest-test catches behavioral problems by running it.
When it runs
honest-check has three invocation surfaces. The rule set is identical across all three.
CLI. honest-check src/ runs the full rule set and exits with code 0 (no errors), 1 (errors found), or 2 (configuration failure). Use --format github for CI annotations. Use --fix for auto-fixable violations.
LSP. honest-check --lsp starts a Language Server Protocol server. Diagnostics appear inline in any LSP-capable editor as you type. Code actions provide auto-fix for fixable rules. Go-to-definition works for vocabulary types and chain links.
Framework startup. In development mode, honest-check runs at startup. Violations are reported in the terminal. The application can be configured to warn, raise, or halt on errors.
What it checks
Rules fall into two categories by firing time.
Construction-time rules fire when honest-framework objects are constructed — vocabularies, chains, state machines. These fire at startup and in the LSP in real time. Key examples: recognizer overlap between two types in the same vocabulary (HC003), a chain with no links (HC007), a catch-all recognizer that accepts nearly all inputs (HC011), a state machine transition table referencing undeclared states or events (HC-SM01, HC-SM02).
Static analysis rules require reading and walking source files. These fire in CLI and LSP mode. Key examples: a link missing its vocabulary declaration (HC001), a chain type mismatch between adjacent links (HC002), an if/elif/else chain dispatching on value instead of a dict lookup (HC-P001), a class with methods that mutate self (HC-P002), inheritance from a non-framework base (HC-P003), I/O inside a non-boundary function (HC-P004), a framework lifecycle hook like useEffect or componentDidMount (HC-P011).
The Honest Code principle rules
The HC-P rules are the static enforcement arm of Honest Code. HC-P001 catches dispatch chains. HC-P002 and HC-P003 catch class-based OOP patterns. HC-P004 catches hidden I/O. HC-P011 catches lifecycle hooks. Each rule links back to the corresponding Honest Code principle so the developer understands not just what is wrong but why.
Violations can be suppressed inline with # honest: ignore HC-P001. Suppressions are always recorded in the output — they never silently accumulate.
The abstract principle
A linter reads code without running it. A type checker reads types without running values through them. honest-check reads dispatch tables and checks structural honesty without running the application.
This is static verification: the analysis happens at the level of the program's text, not its execution. The result is a report: these locations in this code are structurally dishonest, for these reasons.
Why dispatch tables enable static verification
Conventional type checking requires type annotations. Without annotations, the type checker cannot know what types flow through a program. honest-check requires no annotations.
It works because honest-type vocabularies are data — plain dicts declared at module scope. The recognizer for currency_code is the Set {"USD", "EUR", "GBP", ...}. The chain link's accepts vocabulary is a dict of type names to recognizers. The binding table maps type names to slot names.
HC002 — the chain type mismatch rule — checks whether every type that link N+1 declares it accepts is present in the types that link N declares it emits. This is a set containment check: link_n1.accepts ⊆ link_n.emits. No type inference required. No annotations required. The sets are already in the code.
The honest code rules as a theorem
The Honest Code principles are not style conventions. They are structural properties with mechanical consequences:
- No if/elif/else dispatch (HC-P001): a dispatch chain is an implicit, unchecked lookup table. It cannot be verified for totality without running every branch. A dict lookup table can be verified for totality by key inspection. The rule converts unverifiable code into verifiable code.
- No class mutation (HC-P002): a method that mutates
selfhas hidden output — the side effect on the object. A pure function's outputs are entirely in its return value. Static analysis can verify return values; it cannot fully verify side effects. - No I/O in non-boundary functions (HC-P004): I/O in business logic means the test suite requires mocks. Mocks are substitutions that may not accurately model the real system. Moving I/O to declared boundaries means the business logic is testable without substitution.
Each rule converts a structural property that is hard or impossible to verify mechanically into one that is easy to verify mechanically. The rules are not arbitrary restrictions. They are transformations that make the program more amenable to static analysis.
The cost of suppression is visibility
Rule suppression is always visible in the output. A suppressed rule emits an info diagnostic. Suppressions do not silently accumulate. This is a formal property of the suppression system: the presence of a suppression is always observable.
This is the same principle as explicit returns in Haskell's IO monad: the type system does not prevent IO; it makes IO visible. honest-check does not prevent rule violations; it makes violations (and the decision to suppress them) visible.
LSP as continuous verification
The Language Server Protocol integration runs the construction-time rules continuously as the developer types. HC003 (recognizer overlap), HC007 (empty chain), HC-SM01 (undeclared state) fire in real time, before the developer saves the file. The rule set is identical to the CLI. The only difference is the invocation surface.
This eliminates the category of errors discovered at commit time or CI time that could have been caught at edit time. The earlier a structural error is detected, the lower the cost to fix it. honest-check at edit time is cheaper than honest-check at commit time. Both are cheaper than runtime discovery.
Full specification
Invocation Surfaces
honest-check has three invocation surfaces. The rule set is identical across all three.
CLI: honest-check [--format human|json|github|junit] [--severity error|warning|info] [--fix] [--rule HC001] [paths...]
Exit codes: 0 = no errors, 1 = errors found, 2 = configuration error.
LSP: honest-check --lsp — JSON-RPC 2.0 on stdin/stdout. Provides textDocument/publishDiagnostics, textDocument/codeAction (auto-fix), textDocument/hover (rule docs), workspace/symbol (vocabularies and chains), textDocument/definition (go-to-definition for types).
Framework startup: In development mode, honest-check runs at startup. on_error: "warn"|"raise"|"halt". Only construction-time and fast static rules run at startup.
Construction-Time Rules
Fire when honest-framework objects are constructed. Fire at startup, at CLI time, and in the LSP in real time.
| Rule | Severity | Description |
|---|---|---|
| HC003 | Error/Warning | Two types in same vocabulary match the same token (Set overlap = Error; predicate overlap = Info) |
| HC006 | Error | Composed type references unknown base type |
| HC007 | Error | Chain has no links |
| HC011 | Error | Catch-all recognizer (accepts >95% of random sample) |
| HC-SM01 | Error | State in transition table not in states vocabulary |
| HC-SM02 | Error | Event in transition table not in events vocabulary |
| HC-SM05 | Error | Initial state not in states vocabulary |
Static Analysis Rules
Fire at CLI and LSP time. Not at startup.
| Rule | Severity | Description |
|---|---|---|
| HC001 | Error | Function in chain has no vocabulary declared |
| HC002 | Error | Chain type mismatch: link N+1 accepts types not emitted by link N |
| HC004 | Warning | Dead vocabulary: type defined but never bound or composed |
| HC005 | Warning | Unused binding: slot defined but no recognizer produces that type |
| HC008 | Warning | Impure link: I/O, global state, or non-determinism in non-boundary function |
| HC009 | Warning | Predicate may throw on non-matching input |
| HC010 | Warning | Link declares emission of types never produced by its code |
| HC-SM03 | Warning | Unreachable state (no path from initial state) |
| HC-SM04 | Warning | Dead state (no outgoing transitions, not declared terminal) |
| HC-P001 | Error | if/elif/else chain dispatches on value — use dict lookup |
| HC-P002 | Error (method) / Warning (__init__) | Class method mutates self |
| HC-P003 | Error | Inheritance from non-framework base |
| HC-P004 | Error | I/O inside non-boundary function |
| HC-P005 | Warning | isinstance() / type() in business logic |
| HC-P006 | Warning | Cache without profiling annotation |
| HC-P007 | Warning | Instance state in constructor |
| HC-P010 | Error | Pure function returns non-serializable object |
| HC-P011 | Error | Framework lifecycle hook (useEffect, componentDidMount, etc.) |
| HC-HF001 | Error | feature_state() references undeclared flag name |
| HC-HF002 | Warning | Handler table missing entry for declared flag state |
HC-P001 Detection Algorithm
FUNCTION check_HC_P001(ast):
FOR EACH if_node IN ast.all_if_statements:
IF count_elif_branches(if_node) >= 2:
IF condition_is_value_dispatch(if_node):
EMIT error(HC-P001, if_node.location)
Value dispatch: three or more string/enum equality tests on the same variable. Two-branch if/else is not flagged — it may be a legitimate guard.
HC-P002 Detection Algorithm
FUNCTION check_HC_P002(ast):
FOR EACH class_def IN ast.all_classes:
FOR EACH method IN class_def.methods:
IF method assigns to self.*:
severity ← "warning" if method.name = "__init__" else "error"
EMIT diagnostic(severity, HC-P002, method.location)
HC-P003: Allowed Base Classes
Python: TypedDict, Protocol, ABC, Exception, BaseException
JavaScript/TypeScript: Error only
Ruby: StandardError, RuntimeError
Go: no class inheritance — HC-P003 does not apply
Alias Resolution
honest-check resolves import aliases before applying rules. A function imported as from honest_type import vocabulary as v is still recognized as a vocabulary call. AST alias resolution is required for correct HC001, HC002, HC003, HC-HF001, HC-HF002 detection.
Rule Suppression
Inline: # honest: ignore HC-P001 — suppresses for that line.
Block: # honest: disable HC-P001 ... # honest: enable HC-P001
File-level: # honest: disable HC-P001 at top of file.
Config: [rules] disable = ["HC-P006"] in honest-check.toml
All suppressions are recorded in output as info diagnostics. Suppressions never accumulate silently.
Output Formats
Human: file:line:col severity RULE — message with code context.
JSON: { "diagnostics": [{ "rule", "severity", "file", "line", "col", "message", "fixable" }] }
GitHub: ::error file=...,line=...,col=...,title=RULE::message
JUnit XML: <testsuites> with <failure> per error.
Conformance Requirements
| Requirement | Test |
|---|---|
| Exit code 0 when no errors | Run on clean codebase |
| Exit code 1 when errors present | Introduce HC-P001 violation |
| HC-P001 fires on 3+ branch value dispatch | Test with if/elif/elif |
| HC-P001 does not fire on 2-branch if/else | Test with if/else |
| HC002 fires on type mismatch between adjacent links | Test with incompatible chain |
Alias resolution: vocabulary as v still recognized | Test with alias |
| Suppression recorded as info diagnostic | Suppress a rule, check output |
| LSP publishes diagnostics on file save | Edit and save, check IDE |
| All HC-P rules fire on language-appropriate patterns | Per language guidance table |
Reference
- honest-check PyPI Python linter — CLI, LSP, pre-commit hook
- honest-check-architecture.md Full rule set with algorithms