26 KiB
RFC-LINT: core/lint Agent-Native CLI and Adapter Contract
- Status: Implemented
- Date: 2026-03-30
- Applies to:
forge.lthn.ai/core/lint - Standard:
docs/RFC-CORE-008-AGENT-EXPERIENCE.md
Abstract
core/lint is a standalone Go CLI and library that detects project languages, runs matching lint adapters, merges their findings into one report, and writes machine-readable output for local development, CI, and agent QA.
The binary does not bundle external linters. It orchestrates tools already present in PATH, treats missing tools as skipped, and keeps the orchestration report contract separate from the legacy catalog commands.
This RFC describes the implementation that exists in this repository. It replaces the earlier draft that described a future Core service with Tasks, IPC actions, MCP wrapping, build stages, artifact stages, entitlement gates, and scheduled runs. Those designs are not the current contract.
Motivation
Earlier drafts described a future core/lint service that does not exist in this module. Agents dispatched to this repository need the contract that is implemented now, not the architecture that might exist later.
The current implementation has three properties that matter for AX:
- one CLI binary with explicit command paths
- one orchestration DTO (
RunInput) and one orchestration report (Report) - one clear split between adapter-driven runs and the older embedded catalog commands
An agent should be able to read the paths, map the commands, and predict the output shapes without reverse-engineering aspirational features from an outdated RFC.
AX Principles Applied
This RFC follows the Agent Experience standard directly:
- Predictable names over short names:
RunInput,Report,ToolRun,ToolInfo,Service, andAdapterare the contract nouns across the CLI and package boundary. - Comments as usage examples: command examples use real flags and real paths such as
core-lint run --output json .andcore-lint tools --output json --lang go. - Path is documentation: the implementation map is the contract, and
tests/cli/lint/{path}mirrors the command path it validates. - Declarative over imperative:
.core/lint.yamldeclares tool groups, thresholds, and output defaults instead of encoding those decisions in hidden CLI behavior. - One input shape for orchestration:
pkg/lint/service.goownsRunInput. - One output shape for orchestration:
pkg/lint/service.goownsReport. - CLI tests as artifact validation: the Taskfiles under
tests/cli/lint/...are the runnable contract for the binary surface. - Stable sequencing over hidden magic: adapters run sequentially, then tool runs and findings are sorted before output.
Path Map
An agent should be able to navigate the module from the path alone:
| Path | Meaning |
|---|---|
cmd/core-lint/main.go |
CLI surface for run, detect, tools, init, language shortcuts, hook, and the legacy lint namespace |
pkg/lint/service.go |
Orchestrator for config loading, language selection, adapter selection, hook mode, and report assembly |
pkg/lint/adapter.go |
Adapter interface, external adapter registry, built-in catalog fallback, external command execution, and output parsers |
pkg/lint/config.go |
Repo-local config contract and defaults for core-lint init |
pkg/lint/detect_project.go |
Project language detection from markers and file names |
pkg/lint/report.go |
Summary aggregation and JSON/text/GitHub/SARIF writers |
lint.go |
Embedded catalog loader for lint check and lint catalog |
catalog/*.yaml |
Embedded pattern catalog files used by the legacy catalog commands |
tests/cli/lint/... |
CLI artifact tests; the path is the command |
Scope
In scope:
- Project language detection
- Config-driven lint tool selection
- Embedded catalog scanning
- External linter orchestration
- Structured report generation
- Git pre-commit hook installation and removal
- CLI artifact tests in
tests/cli/lint/...
Out of scope:
- Core service registration
- IPC or MCP exposure
- Build-stage compilation checks
- Artifact-stage scans against compiled binaries or images
- Scheduler integration
- Sidecar SBOM file writing
- Automatic tool installation
- Entitlement enforcement
Command Surface
The repository ships two CLI surfaces:
- The root AX surface:
core-lint run,core-lint detect,core-lint tools, and friends - The legacy catalog surface:
core-lint lint checkandcore-lint lint catalog ...
The RFC commands are mounted twice: once at the root and once under core-lint lint .... Both surfaces are real. The root surface is shorter. The namespaced surface keeps the path semantic.
| Capability | Root path | Namespaced alias | Example |
|---|---|---|---|
| Full orchestration | core-lint run [path] |
core-lint lint run [path] |
core-lint run --output json . |
| Go only | core-lint go [path] |
core-lint lint go [path] |
core-lint go . |
| PHP only | core-lint php [path] |
core-lint lint php [path] |
core-lint php . |
| JS group shortcut | core-lint js [path] |
core-lint lint js [path] |
core-lint js . |
| Python only | core-lint python [path] |
core-lint lint python [path] |
core-lint python . |
| Security group shortcut | core-lint security [path] |
core-lint lint security [path] |
core-lint security --ci . |
| Compliance tools only | core-lint compliance [path] |
core-lint lint compliance [path] |
core-lint compliance --output json . |
| Language detection | core-lint detect [path] |
core-lint lint detect [path] |
core-lint detect --output json . |
| Tool inventory | core-lint tools |
core-lint lint tools |
core-lint tools --output json --lang go |
| Default config | core-lint init [path] |
core-lint lint init [path] |
core-lint init /tmp/project |
| Pre-commit hook install | core-lint hook install [path] |
core-lint lint hook install [path] |
core-lint hook install . |
| Pre-commit hook remove | core-lint hook remove [path] |
core-lint lint hook remove [path] |
core-lint hook remove . |
| Embedded catalog scan | none | core-lint lint check [path...] |
core-lint lint check --format json tests/cli/lint/check/fixtures |
| Embedded catalog list | none | core-lint lint catalog list |
core-lint lint catalog list --lang go |
| Embedded catalog show | none | core-lint lint catalog show RULE_ID |
core-lint lint catalog show go-sec-001 |
core-lint js is a shortcut for Lang=js, not a dedicated TypeScript command. TypeScript-only runs use core-lint run --lang ts ... or plain run with auto-detection.
core-lint compliance is also not identical to core-lint run --sbom. The shortcut sets Category=compliance, so the final adapter filter keeps only adapters whose runtime category is compliance. run --sbom appends the compliance config group without that category filter.
RunInput Contract
All orchestration commands resolve into one DTO:
type RunInput struct {
Path string `json:"path"`
Output string `json:"output,omitempty"`
Config string `json:"config,omitempty"`
FailOn string `json:"fail_on,omitempty"`
Category string `json:"category,omitempty"`
Lang string `json:"lang,omitempty"`
Hook bool `json:"hook,omitempty"`
CI bool `json:"ci,omitempty"`
Files []string `json:"files,omitempty"`
SBOM bool `json:"sbom,omitempty"`
}
Input Resolution Rules
Service.Run() resolves input in this order:
- Empty
Pathbecomes. CI=truesetsOutput=githubonly whenOutputwas not provided explicitly- Config is loaded from
--configor.core/lint.yaml - Empty
FailOnfalls back to the loaded config Hook=truewith no explicitFilesreads staged files fromgit diff --cached --name-onlyLangoverrides auto-detectionFilesoverride directory detection for language inference
CLI Output Resolution
The CLI resolves output before it calls Service.Run():
- explicit
--outputwins - otherwise
--cibecomesgithub - otherwise the loaded config
outputvalue is used - if the config output is empty, the CLI falls back to
text
Category and Language Precedence
Tool group selection is intentionally simple and deterministic:
Category=securityselects thelint.securityconfig groupCategory=compliancemeans onlylint.complianceLang=go|php|js|ts|python|...means only that language group- Plain
runuses all detected language groups plusinfra - Plain
run --ciadds thesecuritygroup - Plain
run --sbomadds thecompliancegroup
Lang is stronger than CI and SBOM. If Lang is set, the language group wins and the extra groups are not appended.
Category=style, Category=correctness, and other non-group categories act as adapter-side filters only. They do not map to dedicated config groups.
One current consequence is that grype is listed in the default lint.compliance config group but advertises Category() == "security". core-lint compliance therefore filters it out, while plain core-lint run --sbom still leaves it eligible.
Final adapter selection has one extra Go-specific exception: if Go is present and Category != "compliance", Service.Run() prepends the built-in catalog adapter after registry filtering. That means core-lint security on a Go project can still emit catalog findings tagged security.
Config Contract
Repo-local config lives at .core/lint.yaml.
core-lint init /path/to/project writes the default file from pkg/lint/config.go.
lint:
go:
- golangci-lint
- gosec
- govulncheck
- staticcheck
- revive
- errcheck
php:
- phpstan
- psalm
- phpcs
- phpmd
- pint
js:
- biome
- oxlint
- eslint
- prettier
ts:
- biome
- oxlint
- typescript
python:
- ruff
- mypy
- bandit
- pylint
infra:
- shellcheck
- hadolint
- yamllint
- jsonlint
- markdownlint
security:
- gitleaks
- trivy
- gosec
- bandit
- semgrep
compliance:
- syft
- grype
- scancode
output: json
fail_on: error
paths:
- .
exclude:
- vendor/
- node_modules/
- .core/
Config Rules
- If
.core/lint.yamldoes not exist,DefaultConfig()is used in memory - Relative
--configpaths resolve relative toPath - Unknown tool names in config are inert; the adapter registry is authoritative
- The current default config includes
prettier, but the adapter registry does not yet provide aprettieradapter pathsandexcludeare part of the file schema, but the current orchestration path does not read them; detection and scanning still rely on built-in defaultsLintConfigstill accepts aschedulesmap, but no current CLI command reads or executes it
Detection Contract
pkg/lint/detect_project.go is the only project-language detector used by orchestration commands.
Marker Files
| Marker | Language |
|---|---|
go.mod |
go |
composer.json |
php |
package.json |
js |
tsconfig.json |
ts |
requirements.txt |
python |
pyproject.toml |
python |
Cargo.toml |
rust |
Dockerfile* |
dockerfile |
File Extensions
| Extension | Language |
|---|---|
.go |
go |
.php |
php |
.js, .jsx |
js |
.ts, .tsx |
ts |
.py |
python |
.rs |
rust |
.sh |
shell |
.yaml, .yml |
yaml |
.json |
json |
.md |
markdown |
Detection Rules
- Directory traversal skips
vendor,node_modules,.git,testdata,.core, and any hidden directory - Results are de-duplicated and returned in sorted order
core-lint detect --output json tests/cli/lint/check/fixturescurrently returns["go"]
Execution Model
Service.Run() is the orchestrator. The current implementation is sequential, not parallel.
Step 1: Load Config
LoadProjectConfig() returns the repo-local config or the in-memory default.
Step 2: Resolve File Scope
- If
Fileswas provided, only those files are considered for language detection and adapter arguments - If
Hook=trueandFilesis empty, staged files are read from Git - Otherwise the whole project path is scanned
Step 3: Resolve Languages
Langwins firstFilesare used nextDetect(Path)is the fallback
Step 4: Select Adapters
pkg/lint/service.go builds a set of enabled tool names from config, then filters the registry from pkg/lint/adapter.go.
Special case:
- If
gois present in the final language set andCategory != "compliance", a built-incatalogadapter is prepended automatically
Step 5: Run Adapters
Every selected adapter runs with the same contract:
type Adapter interface {
Name() string
Available() bool
Languages() []string
Command() string
Entitlement() string
RequiresEntitlement() bool
MatchesLanguage(languages []string) bool
Category() string
Fast() bool
Run(ctx context.Context, input RunInput, files []string) AdapterResult
}
Execution rules:
- Missing binaries become
ToolRun{Status: "skipped"} - External commands run with a 5 minute timeout
- Hook mode marks non-fast adapters as
skipped - Parsed findings are normalised, sorted, and merged into one report
- Adapter order becomes deterministic after
sortToolRuns()andsortFindings()
Step 6: Compute Pass or Fail
passesThreshold() applies the configured threshold:
fail_on |
Passes when |
|---|---|
error or empty |
summary.errors == 0 |
warning |
summary.errors == 0 && summary.warnings == 0 |
info |
summary.total == 0 |
CLI exit status follows report.Summary.Passed, not raw tool state. A skipped or timeout tool run does not fail the command by itself.
Catalog Surfaces
The repository has two catalog paths. They are related, but they are not the same implementation.
Legacy Embedded Catalog
These commands load the embedded YAML catalog via lint.go:
core-lint lint checkcore-lint lint catalog listcore-lint lint catalog show
The source of truth is catalog/*.yaml.
Orchestration Catalog Adapter
core-lint run, core-lint go, and the other orchestration commands prepend a smaller built-in catalog adapter from pkg/lint/adapter.go.
That adapter reads the hard-coded defaultCatalogRulesYAML constant, not catalog/*.yaml.
Today the fallback adapter contains these Go rules:
go-cor-003go-cor-004go-sec-001go-sec-002go-sec-004
The overlap is intentional, but the surfaces are different:
lint checkreturns raw catalog findings with catalog severities such asmediumorhighrunnormalises those findings into report severitieswarning,error, orinfo
An agent must not assume that core-lint lint check and core-lint run execute the same rule set.
Adapter Inventory
The implementation has two adapter sources in pkg/lint/adapter.go:
defaultAdapters()defines the external-tool registry exposed bycore-lint toolsnewCatalogAdapter()defines the built-in Go fallback injected byService.Run()when Go is in scope
ToolInfo Contract
core-lint tools returns the runtime inventory from Service.Tools():
type ToolInfo struct {
Name string `json:"name"`
Available bool `json:"available"`
Languages []string `json:"languages"`
Category string `json:"category"`
Entitlement string `json:"entitlement,omitempty"`
}
Inventory rules:
- results are sorted by
Name --langfilters viaAdapter.MatchesLanguage(), not strict equality on theLanguagesfield- wildcard adapters with
Languages() == []string{"*"}still appear under any--langfilter - category tokens also match, so
core-lint tools --lang securityreturns security adapters plus wildcard adapters Availablereflects aPATHlookup at runtime, not config membershipEntitlementis descriptive metadata; the current implementation does not enforce it- the built-in
catalogadapter is not returned bycore-lint tools; it is injected only duringrun-style orchestration on Go projects
Injected During Run
| Adapter | Languages | Category | Fast | Notes |
|---|---|---|---|---|
catalog |
go |
correctness |
yes | Built-in regex fallback rules; injected by Service.Run(), not listed by core-lint tools |
Go
| Adapter | Category | Fast |
|---|---|---|
golangci-lint |
correctness |
yes |
gosec |
security |
no |
govulncheck |
security |
no |
staticcheck |
correctness |
yes |
revive |
style |
yes |
errcheck |
correctness |
yes |
PHP
| Adapter | Category | Fast |
|---|---|---|
phpstan |
correctness |
yes |
psalm |
correctness |
yes |
phpcs |
style |
yes |
phpmd |
correctness |
yes |
pint |
style |
yes |
JS and TS
| Adapter | Category | Fast |
|---|---|---|
biome |
style |
yes |
oxlint |
style |
yes |
eslint |
style |
yes |
typescript |
correctness |
yes |
Python
| Adapter | Category | Fast |
|---|---|---|
ruff |
style |
yes |
mypy |
correctness |
yes |
bandit |
security |
no |
pylint |
style |
yes |
Infra and Cross-Project
| Adapter | Category | Fast |
|---|---|---|
shellcheck |
correctness |
yes |
hadolint |
security |
yes |
yamllint |
style |
yes |
jsonlint |
style |
yes |
markdownlint |
style |
yes |
gitleaks |
security |
no |
trivy |
security |
no |
semgrep |
security |
no |
syft |
compliance |
no |
grype |
security |
no |
scancode |
compliance |
no |
Adapter Parsing Rules
- JSON tools are parsed recursively and schema-tolerantly by searching for common keys such as
file,line,column,code,message, andseverity - Text tools are parsed from
file:line[:column]: message - Non-empty output that does not match either parser becomes one synthetic finding with
code: diagnostic - A failed command with no usable parsed output becomes one synthetic finding with
code: command-failed - Duplicate findings are collapsed on
tool|file|line|column|code|message ToolRun.Versionexists in the report schema but is not populated yet
Entitlement Metadata
Adapters still expose Entitlement() and RequiresEntitlement(), but Service.Run() does not enforce them today. The metadata is present; the gate is not.
Output Contract
Orchestration commands return one report document:
type Report struct {
Project string `json:"project"`
Timestamp time.Time `json:"timestamp"`
Duration string `json:"duration"`
Languages []string `json:"languages"`
Tools []ToolRun `json:"tools"`
Findings []Finding `json:"findings"`
Summary Summary `json:"summary"`
}
type ToolRun struct {
Name string `json:"name"`
Version string `json:"version,omitempty"`
Status string `json:"status"`
Duration string `json:"duration"`
Findings int `json:"findings"`
}
type Summary struct {
Total int `json:"total"`
Errors int `json:"errors"`
Warnings int `json:"warnings"`
Info int `json:"info"`
Passed bool `json:"passed"`
BySeverity map[string]int `json:"by_severity,omitempty"`
}
ToolRun.Status has four implemented values:
| Status | Meaning |
|---|---|
passed |
The adapter ran and emitted no findings |
failed |
The adapter ran and emitted findings or the command exited non-zero |
skipped |
The binary was missing or hook mode skipped a non-fast adapter |
timeout |
The command exceeded the 5 minute adapter timeout |
Finding is shared with the legacy catalog scanner:
type Finding struct {
Tool string `json:"tool,omitempty"`
File string `json:"file"`
Line int `json:"line"`
Column int `json:"column,omitempty"`
Severity string `json:"severity"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Category string `json:"category,omitempty"`
Fix string `json:"fix,omitempty"`
RuleID string `json:"rule_id,omitempty"`
Title string `json:"title,omitempty"`
Match string `json:"match,omitempty"`
Repo string `json:"repo,omitempty"`
}
Finding Normalisation
During orchestration:
Codefalls back toRuleIDMessagefalls back toTitle- empty
Toolbecomescatalog - file paths are made relative to
Pathwhen possible - severities are collapsed to report levels:
| Raw severity | Report severity |
|---|---|
critical, high, error, errors |
error |
medium, low, warning, warn |
warning |
info, note |
info |
Output Modes
| Mode | How to request it | Writer |
|---|---|---|
| JSON | --output json |
WriteReportJSON |
| Text | --output text |
WriteReportText |
| GitHub annotations | --output github or --ci |
WriteReportGitHub |
| SARIF | --output sarif |
WriteReportSARIF |
Stream Contract
For run-style commands, the selected writer always writes the report document to stdout.
If the report fails the configured threshold, the CLI still writes the report to stdout, then returns an error. The error path adds human-facing diagnostics on stderr.
Agents and CI jobs that need machine-readable output should parse stdout and treat stderr as diagnostic text.
Hook Mode
core-lint run --hook is the installed pre-commit path.
Implementation details:
- staged files come from
git diff --cached --name-only - language detection runs only on those staged files
- adapters with
Fast() == falseare markedskipped - output format still follows normal resolution rules; hook mode does not force text output
core-lint hook installwrites a managed block into.git/hooks/pre-commitcore-lint hook removeremoves only the managed block
Installed hook block:
# core-lint hook start
# Installed by core-lint
exec core-lint run --hook
# core-lint hook end
If the hook file already exists, install appends a guarded block instead of overwriting the file. In that appended case the command line becomes core-lint run --hook || exit $? rather than exec core-lint run --hook.
Test Contract
The CLI artifact tests are the runnable contract for this RFC:
| Path | Command under test |
|---|---|
tests/cli/lint/check/Taskfile.yaml |
core-lint lint check |
tests/cli/lint/catalog/list/Taskfile.yaml |
core-lint lint catalog list |
tests/cli/lint/catalog/show/Taskfile.yaml |
core-lint lint catalog show |
tests/cli/lint/detect/Taskfile.yaml |
core-lint detect |
tests/cli/lint/tools/Taskfile.yaml |
core-lint tools |
tests/cli/lint/init/Taskfile.yaml |
core-lint init |
tests/cli/lint/run/Taskfile.yaml |
core-lint run |
tests/cli/lint/Taskfile.yaml |
aggregate CLI suite |
The planted bug fixture is tests/cli/lint/check/fixtures/input.go.
Current expectations from the test suite:
lint check --format=jsonfindsgo-cor-003ininput.gorun --output json --fail-on warningwrites one report document tostdout, emits failure diagnostics onstderr, and exits non-zerodetect --output jsonreturns["go"]for the shipped fixturetools --output json --lang goincludesgolangci-lintandgovulncheckinitwrites.core/lint.yaml
Unit-level confirmation also exists in:
cmd/core-lint/main_test.gopkg/lint/service_test.gopkg/lint/detect_project_test.go
Explicit Non-Goals
These items are intentionally not part of the current contract:
- no Core runtime integration
- no
core.Taskpipeline - no
lint.static,lint.build, orlint.artifactaction graph - no scheduled cron registration
- no sidecar
sbom.cdx.jsonorsbom.spdx.jsonoutput - no parallel adapter execution
- no adapter entitlement enforcement
- no guarantee that every config tool name has a matching adapter
Any future RFC that adds those capabilities must describe the code that implements them, not just the aspiration.
Compatibility
This RFC matches the code that ships today:
- a standard Go CLI binary built from
cmd/core-lint - external tools resolved from
PATHat runtime - no required Core runtime, IPC layer, scheduler, or generated action graph
The contract is compatible with the current unit tests and CLI Taskfile tests because it describes the existing paths, flags, DTOs, and outputs rather than a future service boundary.
Adoption
This contract applies immediately to:
- the root orchestration commands such as
core-lint run,core-lint detect,core-lint tools,core-lint init, andcore-lint hook - the namespaced aliases under
core-lint lint ... - the legacy embedded catalog commands under
core-lint lint checkandcore-lint lint catalog ...
Future work that adds scheduler support, runtime registration, entitlement enforcement, parallel execution, or SBOM file outputs must land behind a new RFC revision that points to implemented code.
References
docs/RFC-CORE-008-AGENT-EXPERIENCE.mddocs/index.mddocs/development.mdcmd/core-lint/main.gopkg/lint/service.gopkg/lint/adapter.gotests/cli/lint/Taskfile.yaml
Changelog
- 2026-03-30: Rewrote the RFC to match the implemented standalone CLI, adapter registry, fallback catalog adapter, hook mode, and CLI test paths
- 2026-03-30: Clarified the implemented report boundary, category filtering semantics, ignored config fields, and AX-style motivation/compatibility/adoption sections
- 2026-03-30: Documented the
stdoutversusstderrcontract for failingruncommands and the non-stricttools --langmatching rules