Development guide¶
How to change lumioguard CC safely. Read Contribute first for issues and pull requests.
Design principles¶
People and agents act on the tool's output without reading its code. Each principle keeps that output accurate. Breaking one needs a discussion first.
- Incomplete is not clean. A parse failure, an unsupported included file or a missing required
coverage report gives status
incompleteand exit code 2, which beats exit code 1. Otherwise the most broken code would look the best. - Same input, same output. Results are sorted with fixed keys; only
runchanges. Agents and reviewers compare runs, so noise makes every difference suspect. - No silent zeros. A value that cannot be measured is
nullwith a status and a reason. A made-up number looks like evidence. - Compare only like with like. A stored baseline is used only when schema, adapter identities and configuration hash match, and only new or worsened blocking findings fail.
checknever writes. Onlyinitandbaseline createwrite files.checkruns constantly, often automatically, so it must always be safe.- One JSON document on stdout. Everything else goes to stderr. Tools parse stdout directly, so one stray line breaks them.
- Metrics are computed once. Complexity lives only in
internal/adapter/structure, and a metric ID never changes meaning. - Adapters never guess. An import that should resolve but does not is a warning, and outside code is counted as external. A graph with hidden gaps gives confident, wrong answers.
- Official, checkable supply chain. Every dependency is its canonical upstream, the standard library
is preferred, and copied code is recorded in
internal/thirdparty/components.go. - Time alternatives in separate processes. The ANTLR parser caches state, so whichever approach runs second in one process looks faster.
Key decisions¶
- One shared model for every language. Adapters translate syntax into a small model of functions and control flow, and the metrics run once over it. Per-language walkers would drift apart.
- One Go binary with the parsers compiled in. No runtime to install, so results are the same on a
laptop, in CI and in an agent sandbox. TypeScript uses Microsoft's typescript-go parser, copied because
its packages are internal. Python uses a parser written for this project. Java uses an ANTLR-generated
parser;
tools/java-grammar-syncasserts that generated code never calls the runtime's version check. Go uses the standard library'sgo/parser, whose behaviour is fixed by the Go release a binary is built with; release builds pin that release. - A Go import points at one file. Fan-out counts files, and a Go import names a package. The edge goes to the package's first non-test file in name order, so fan-out equals the number of packages imported instead of growing with the size of each package.
- Source tokens as the size of a codebase.
size.file_tokensandsize.total_tokensreuse the adapters' clone tokens. They are not a language model's tokens, but they move with them, and they are free to compute and deterministic. The summary prints the total and its delta, because the amount of code an agent must read is the cost every later task pays. - SARIF is derived, JSON is the record.
--format sarifis built from the same report and holds less: active findings, their locations and analysis notifications. Nothing exists only in SARIF, so tools and agents that need measurements or resolved findings read the JSON report. - One finding per copied block, named after where the copies live. Matching token windows are extended to the longest region the copies share, so a 40-line copy is one finding, not thirty overlapping ones. The finding's identity is the sorted set of files and enclosing functions of the copies, not the copied text: editing the block or the code around it keeps the finding, as renaming a function does not for the complexity rules. Import declarations are skipped, because two files that import the same modules are not copies of each other.
- No overall debt score. Complexity, duplication and coupling have different units, and any weighting
would be arbitrary. The
worklistcommand orders places without one: by the number of rules a place breaks, then by how far it is over its own limit. That is a place to start, and the docs say so. - Advisory defaults. There is no universal complexity limit, so size and complexity only warn by default. Cycles and declared boundaries block, because they describe intended structure.
- Import coverage, never run tests. Running a project's tests is slow, machine-specific and can have side effects.
- Bounded, opt-in agent integration. The Claude Code hook blocks at most twice per session, compares
with
HEADby default, lists only findings that fail, and names the exact command to reproduce them, so the loop can end on code with existing debt. - Guides ship in the binary.
lumioguard-cc guidetopics live ininternal/guide/topics/, so agents read instructions that match the installed version. Tests fail if a guide names a command or flag that does not exist.
Code layout¶
Dependencies point inwards, towards domain.
flowchart TD
MAIN["cmd/lumioguard-cc"] --> COMPOSE["compose<br/>wires everything together"]
COMPOSE --> CLI["cli<br/>commands and flags"]
CLI --> APP["app<br/>one service per command"]
CLI --> REPORT["report<br/>summary and JSON"]
APP --> ENGINE["engine<br/>runs a check"]
APP --> GIT["git"]
APP --> BASELINE["baseline store"]
ENGINE --> ADAPTERS["language adapters<br/>typescript, python, java, golang"]
ADAPTERS --> STRUCTURE["structure<br/>shared model and<br/>complexity metrics"]
ENGINE --> ANALYSIS["cross-file analysis<br/>graph, duplication, coverage"]
ENGINE --> POLICY["policy and comparison"]
STRUCTURE --> DOMAIN["domain<br/>measurements, findings, reports"]
ANALYSIS --> DOMAIN
POLICY --> DOMAIN
| Package | Responsibility |
|---|---|
cmd/lumioguard-cc, internal/compose |
Entry point and composition root; only compose creates concrete types |
internal/cli |
Cobra commands: parse flags, call one service, render, map the exit code |
internal/report |
The summary, the JSON report and the SARIF log derived from it |
internal/app |
One service per command, with collaborators behind interfaces in ports.go |
internal/engine |
One analysis: discovery, parallel adapters, analyzers, thresholds, ordering, comparison |
internal/adapter |
LanguageAdapter, ImportResolver, Registry, ModuleIndex, measurement builder |
internal/adapter/structure |
Shared control-flow model and the only complexity implementation |
internal/adapter/{typescript,python,java,golang} |
Parse, translate to structure, tokens, imports, resolver |
internal/analysis/{graph,duplication,tokens,coverage} |
Cross-file checks |
internal/domain |
Measurements, findings, reports, config, baselines, exit codes; no I/O |
internal/guide |
The task guides printed by lumioguard-cc guide |
internal/worklist |
Groups a report's findings by place and orders them for lumioguard-cc worklist |
internal/{comparison,policy,baseline,git,config,discovery,report,explain,language} |
Single-purpose services |
internal/thirdparty/tsgo, internal/adapter/java/syntax |
Copied and generated parsers; never edit by hand |
tools/* |
Sync, audit, notices, corpus and benchmark commands; not in the binary |
domain imports nothing from this module, adapters never import each other or engine, and
structure depends only on domain.
Adding a language¶
Open an issue first: a language is a long-term commitment that ends up in people's baselines.
- Choose a parser that keeps the binary pure Go: a maintained Go parser, a parser generated from a
maintained grammar, copied upstream code refreshed by a tool, or, as a last resort, a hand-written
parser validated on a large corpus. Record copied code in
internal/thirdparty/components.go. - Register the extensions in
internal/language. That table feedsSupports, the default include patterns and report counts. Note inCHANGELOG.mdthat the default configuration hash changes. - Implement
adapter.LanguageAdapterininternal/adapter/<language>. A syntax error is a required diagnostic such as<language>.parse_failed, never a partial result. - Translate functions into
structure.Function, callstructure.AssignSymbols, thestructuremetrics andsourcetext.CountSourceLines, thenadapter.FunctionMeasurements. Never compute complexity in the adapter. - Produce tokens without comments, and collect imports with an
ImportResolver. - Wire it into
compose.NewRegistryandtools/parse-corpus. - Test it like the other adapters: the shared reference fixture (
score: cyclomatic 5, nesting 3, parameters 3, cognitive 7), every mapped construct, tokens, imports, resolver cases, a parse failure, and a mixed-language repository. Then rungo run ./tools/parse-corpus -lang <language> -dir <sources>. - Document it in the rules pages, Languages and limits
and
CHANGELOG.md. Never advertise a language before every step is done.
Adding or changing a rule¶
Rule IDs are a public interface: people store them in baselines and agents parse them. Open an issue first.
- Add the ID to
internal/domain/metric.goas<category>.<name>. - Compute it in the right place: control flow in
internal/adapter/structure, function size in the adapters, cross-file checks as aRepositoryAnalyzerininternal/analysis. Give every measurement an exactVariant, a status and evidence. - For a threshold, add the policy to
MetricsConfigandConfig.PolicyForininternal/domain/config.go, an advisory default ininternal/config/defaults.go, and the key tometricNamesininternal/config/validate.go. A new key invalidates existing configuration files, so bump the schema version. - Update
schemas/andschemas/schemas_test.go,internal/explain/catalog.go, the guides ininternal/guide/topics/, and the matching rules page with a worked example whose numbers come from running the binary. - Test with hand-checked values, and extend every adapter's reference fixtures.
Changing how an existing rule counts:
- Change the measurement
Variantand bumpVersionin every affected adapter. - Update fixtures and explain each changed expectation in the pull request.
- Rerun the worked examples with
sh .examples/run.sh bin/lumioguard-ccand update theirREPORT.mdfiles. - Say in
CHANGELOG.mdthat users must create replacement stored baselines.
Testing¶
go test ./...
gofmt -l . # must print nothing
go vet -unreachable=false ./... # the generated Java parser trips only this check
golangci-lint run ./... # must report 0 issues
go build -o bin/lumioguard-cc ./cmd/lumioguard-cc
sh .examples/run.sh bin/lumioguard-cc # worked examples
| Test | What it proves |
|---|---|
TestRepeatedAnalysisIsDeterministicApartFromRunMetadata |
A 1-worker and an 8-worker run produce identical JSON |
TestParseFailureIsIncomplete, TestUnsupportedLanguageIsNeverGuessed |
Code that was not analyzed never looks clean |
TestGitBaseComparisonDoesNotModifyCheckout |
--base uses real Git and leaves the checkout alone |
TestClaudeStopHook... |
The hook's default reference, its conflict check and its two-attempt limit |
TestGuidesMentionOnlyRealCommandsAndFlags |
The built-in guides match the command tree |
TestProducedReportAndBaselineMatchSchemas |
Real output matches the published schemas |
TestSarifOutputListsActiveFindingsWithLocations |
SARIF carries every active finding with its file, line and a stable fingerprint |
TestResolveGoImports, TestAnalyzeFileFindsTheNearestModule |
Go imports follow go.mod files, and test files never stand for a package |
- Conventions: standard
testingonly,t.TempDir(),t.Helper(), and realgitwitht.Skipwhen it is missing. - Worked examples: the runner checks that each bad version fails and its refactor resolves every
finding. Never fix
.examples/*/before/. - Parser changes: run
go run ./tools/parse-corpuson a real corpus.
Documentation¶
The site is built with Zensical from .documentations/.
pip install -r requirements-docs.txt
make docs # build dist/site in strict mode, failing on broken links
make docs-serve # preview at http://localhost:8000
Zensical skips folders whose names start with a dot, so both targets copy .documentations/ to
dist/docs-src first. Add every new page to nav in zensical.toml.
Releasing¶
The project uses Semantic Versioning. Before 1.0, minor releases may change measurements or formats.
- The Build workflow is green on Linux, macOS and Windows.
make auditreports nothing, andmake noticesleavesTHIRD-PARTY-NOTICES.mdcurrent.- The worked examples still match their reports.
CHANGELOG.mdmoves "Unreleased" under the new version, calling out baseline or configuration breaks.- The version in
internal/product/product.gomatches the new tag. - Tag the commit
vX.Y.Zand push the tag. The Release workflow verifies the commit, builds archives for Linux, macOS and Windows on amd64 and arm64 with the license and notices inside, and publishes a GitHub release with checksums and the changelog section as notes. - The GitHub Action in
action.ymldownloads that release by the same tag, souses: lumioguard/lumioguard-cc@vX.Y.Zworks as soon as the release exists.
The tool checks its own code: the root .lumioguard-cc.json includes every Go file except the copied
and generated parsers, declares the dependency rules from Code layout as boundaries,
and blocks every rule. Existing findings are debt to reduce; a change must not add to them.
Refreshing a copied parser: change the pin in internal/thirdparty/components.go, run
go run ./tools/tsgo-sync or go run ./tools/java-grammar-sync, bump the adapter's ParserVersion, then
run the tests, examples, a corpus run, make audit and make notices.