8.2 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Project Overview
glint is a CLI tool that validates and lints .gitlab-ci.yml pipelines locally, without a GitLab server. It resolves extends: inheritance, fetches remote include: project: templates and include: component: catalog entries, then runs a set of lint rules over the fully-merged pipeline.
Goals: catch misconfigured pipelines early in a developer's workflow, before pushing to GitLab. Eventual goal: produce a visual graph of the pipeline DAG.
Non-goals: full GitLab CI emulation, job execution, secret resolution, or runner management.
Architecture
Data flow
model.Parse(path) // two-pass YAML → *Pipeline
└─ resolver.ResolveIncludes // fetch project: / component: includes, merge
└─ resolver.Resolve // resolve extends: inheritance chains
└─ linter.Lint // run all lint rules → []Finding
This order is intentional and must be preserved: includes must be merged before extends: resolution so that jobs defined in remote templates are available as base templates; extends: must be resolved before linting so that derived jobs carry their full merged definition.
Package responsibilities
| Package | Role |
|---|---|
internal/model |
Data structures (Pipeline, Job, Rule, …) and YAML parser |
internal/linter |
Lint rules; each rule returns []Finding |
internal/resolver |
extends: resolution and include: merging |
internal/fetcher |
GitLab REST API client (token auth, file fetch) |
internal/graph |
Mermaid graph generators (include dependencies, pipeline jobs) |
cmd/glint |
CLI entrypoint, flag parsing, output formatting |
Two-pass YAML parser (internal/model/parser.go)
ParseBytes runs two yaml.Unmarshal passes over the same input:
- Raw pass — into
map[string]yaml.Nodeto collect every top-level key without type assumptions. - Typed pass — into
*Pipelineto decode reserved keys (stages,variables,default,include,workflow,spec) into typed structs.
Keys that survive the raw pass and are not in ReservedKeys are decoded individually as Job structs. This approach handles the open-ended job-name namespace without requiring a catch-all map in the typed struct.
When adding a new top-level reserved key: add it to model.ReservedKeys and add the corresponding typed field to Pipeline.
any fields in Job
Many Job fields (e.g. Artifacts, Cache, Image, Trigger) are typed as any because GitLab CI allows both scalar and map forms. The linter type-asserts these at check time. Follow the existing pattern in internal/linter/keywords.go:
switch v := job.Artifacts.(type) {
case map[string]any:
// map form
case nil:
// not set
}
Never change an any field to a concrete struct type unless the GitLab CI spec guarantees only one form — doing so will silently drop the other form during YAML decode.
Adding a New Lint Rule
-
Add the check function to the appropriate file in
internal/linter/:- Per-job keyword constraint →
keywords.go, called fromcheckJobKeywords - Cross-job graph rule (needs, deps, extends) → dedicated file (
needs.go,dependencies.go) - Pipeline-level rule →
linter.go, called fromLint
- Per-job keyword constraint →
-
Return
[]Findingwith the correctSeverity(ErrororWarning) and aJobfield set when the finding is job-scoped, empty for pipeline-level. -
Add a testdata fixture that triggers the new rule, and add it to the
validatetask inTaskfile.yml. -
Document the rule in the lint rules table in
README.md.
Finding severity guide
| Severity | Use when |
|---|---|
Error |
The pipeline will definitely fail or behave incorrectly |
Warning |
Deprecated usage, best-practice deviation, or a condition that may be wrong |
Adding a New Model Field
- Add the field to
model.Jobwith the correctyaml:tag. - If the field has constrained values, add a validity map to
internal/linter/keywords.go(e.g.validJobWhen). - Add the check to
checkJobKeywordsand call it from there. - Add both a valid and an invalid fixture case to
testdata/keywords_valid.yml/testdata/keywords_invalid.yml.
Go Code Style
- Format:
gofmt(enforced viago vetin thecitask). Never commit unformatted code. - Imports: group stdlib / external / internal with a blank line between groups.
- Errors: always wrap with context using
fmt.Errorf("doing X: %w", err). Use%w(not%s) to preserve the error chain. - No panics in library code (
internal/). Panics are acceptable only inmain()for truly unrecoverable startup failures — prefer returning errors. - No global mutable state. All configuration flows through function parameters or structs (see
fetcher.GitLabConfig). - Exported symbols must have a doc comment. Unexported helpers do not need one unless the logic is non-obvious.
- Table-driven tests are the default style for unit tests. Group cases in a
[]struct{ name, input, want }slice and range over them. - Dependencies: prefer the standard library. The only current external dependency is
gopkg.in/yaml.v3; keep it that way unless there is a compelling reason. go.summust always be committed alongsidego.mod.
Commit Message Guidelines
IMPORTANT: This project uses Conventional Commits format.
All commit messages must follow this format:
<type>(<scope>): <description>
[optional body]
[optional footer(s)]
Types:
feat: A new featurefix: A bug fixdocs: Documentation only changesrefactor: Code change that neither fixes a bug nor adds a featuretest: Adding missing tests or correcting existing testschore: Changes to build process or auxiliary toolsperf: Performance improvementsstyle: Code style changes (formatting, missing semicolons, etc.)
Scopes:
linter: changes to lint rules (internal/linter/)model: data structures or YAML parser (internal/model/)resolver: extends/include resolution (internal/resolver/)fetcher: GitLab API client (internal/fetcher/)graph: Mermaid graph generators (internal/graph/)cli: CLI entrypoint (cmd/glint/)testdata: fixture files onlydocs: README, CHANGELOG, or other documentationbuild: Taskfile, go.mod, go.sum, CI configclaude: CLAUDE.md changes
Breaking Changes:
Add ! after type/scope for breaking changes (e.g. changed CLI flags, removed output fields):
feat(cli)!: rename --token to --api-token
Note: Always include a scope in parentheses, even for documentation changes.
Development Workflow
task ci # full check: vet → test → build → validate (run before every commit)
task build # compile the binary
task test # run Go unit tests
task lint-go # go vet only
task validate # run the binary against all testdata fixtures
task clean # remove build artifacts
task ci must pass before any commit is created.
Testdata fixtures
Every fixture in testdata/ is run by task validate. Files whose expected behaviour is clean (exit 0) are listed with ignore_error: false; files that are expected to produce errors (exit 1) use ignore_error: true. Keep both categories; do not use ignore_error: true for a fixture that is supposed to be clean.
Environment Variables (for manual testing)
| Variable | Purpose |
|---|---|
GITLAB_TOKEN |
Personal access token (read_api scope) |
CI_JOB_TOKEN |
CI/CD job token (when running inside a pipeline) |
GITLAB_PRIVATE_TOKEN |
Legacy PAT name (lowest priority) |
CI_SERVER_URL |
GitLab instance URL (takes precedence over GITLAB_URL) |
GITLAB_URL |
GitLab instance URL fallback |
Token resolution order (first non-empty wins): GITLAB_TOKEN → CI_JOB_TOKEN → GITLAB_PRIVATE_TOKEN.
URL resolution order: --gitlab-url flag → CI_SERVER_URL → GITLAB_URL → https://gitlab.com.
README and CHANGELOG
On each modification, complete README.md and CHANGELOG.md with the changes made.