Skip to content

feat(cli): add conduit pipelines validate#2574

Merged
devarismeroxa merged 2 commits into
mainfrom
feat/cli-pipeline-validate
Jul 8, 2026
Merged

feat(cli): add conduit pipelines validate#2574
devarismeroxa merged 2 commits into
mainfrom
feat/cli-pipeline-validate

Conversation

@devarismeroxa

Copy link
Copy Markdown
Contributor

Summary

Implements conduit pipelines validate <path> (alias conduit pipeline validate) per
docs/design-documents/20260707-cli-pipeline-validate.md, built on the shared v0.17
"CLI as product" foundations merged in #2572 (cecdysis.CommandWithResult, cmd/conduit/internal/ui,
pkg/conduit/exitcode) and the contract in docs/design-documents/20260707-cli-output-conventions.md.

  • Offline parse -> enrich -> validate over pkg/provisioning/config — the same machinery
    conduit run's provisioning path uses, never dials the API.
  • Shared engine in cmd/conduit/internal/validate (Run, one Report type both the human
    renderer and --json marshal from) — designed so lint/dry-run can reuse it in a follow-up
    PR, per the design doc's shipping order.
  • Registered on the existing PipelinesCommand (pipeline/pipelines alias already in place).
  • Collects every finding across every resolved file — no fail-fast. Each finding carries
    code + configPath + message + suggestion.
  • Directory input aggregates every .yml/.yaml file (not recursed), showing passing files too
    (✓ <file>). -q/--quiet suppresses passing/progress lines only, not failures or the summary.
  • Exit 0 clean, 2 on any finding, via pkg/conduit/exitcode. No --strict (validate is
    errors-only, per the conventions doc — that flag belongs to lint/dry-run).

Review-flagged must-fixes (from the design doc's review outcome)

  • cerrors.ForEach on the unwrapped cerrors.Join. config.Validate and the yaml parser
    both already return a raw cerrors.Join(...); the engine walks that directly, never after
    re-wrapping it with %w (which would collapse N findings into one — this was called out as
    the top masked-bug risk). See TestValidateFile_ForEachUnwrappedJoin_YieldsAllFindings, which
    proves both directions.
  • Cross-file/-document duplicate pipeline ID detection is new engine code
    (checkCrossFileDuplicateIDs), not a reuse of provisioning.Service.findDuplicateIDs's bare
    sentinel (ErrDuplicatedPipelineID) — that method is a same-process, flattened, skip-and-continue
    check suited to provisioning's recovery path. The new engine code needed a real, located
    finding, so it's new: a new provisioning.CodePipelineIDDuplicate code + a configPath into
    the offending file's own pipelines list, reported against every file/document that
    contributes an occurrence.
  • Populated Suggestion in every pkg/provisioning/config validator — fieldError took no
    suggestion before this PR and always shipped one empty. Additive; the connector-missing-plugin
    and connector-id-duplicate suggestions match the design doc's own FAIL-output example verbatim
    (locked in by a dedicated test).
  • Enrich before Validate, matching service.go's provisioning order (provisionPipeline).

Supporting refactor

Extracted config.ResolveFiles (+ YAMLFilesInDir, IsYAMLFile) as the single
file/directory-resolution implementation shared by pkg/provisioning.Service (config-file
provisioning at startup) and this new offline command — Service.getPipelineConfigFiles now
delegates to it instead of carrying its own private copy. Existing provisioning tests
(TestService_getYamlFiles*, TestService_Init_Delete) still pass unmodified.

Additive change to cecdysis

cecdysis.Outcome gained an ExitErr field, and CommandWithResultDecorator's success path
now returns it instead of unconditionally nil. This was necessary, not optional: per the CLI
output conventions, a validation run that finds problems is ok:false/error:null — the run
itself succeeded, so ExecuteWithResult must return a nil error (that's the documented contract
for "domain failure" vs. "hard command failure"). But cmd/conduit/cli derives the process exit
code entirely from cmd.Execute()'s returned error, and the pre-existing decorator's success
path always returned nil regardless of Outcome.OK — so without this change, validate could
never exit non-zero on a finding. ExitErr is returned after the envelope/human output is
already written, is never itself rendered, and is nil (today's default behavior, unchanged) for
every other command using this decorator. Covered by three new tests in cecdysis/result_test.go
(JSON path, human path, and the nil-ExitErr default-unaffected case).

Adversarial self-review

  • Re-read the diff hunting for the ForEach/re-wrap masked-bug specifically — validateFile in
    engine.go calls cerrors.ForEach directly on parser.Parse's and config.Validate's return
    values, with no cerrors.Errorf("...: %w", err) wrap anywhere in between. Added the dedicated
    regression test rather than trusting a read-through alone.
  • Verified end-to-end with the built binary (not just unit tests): single valid file, single file
    with 5 field errors, a directory with 2 passing + 1 failing file, two files sharing a pipeline
    ID, --json, --quiet — confirmed exit codes (0/2) and rendered output match by hand before
    trusting the test suite.
  • Confirmed the offline invariant structurally, not just by a passing test: grepped
    cmd/conduit/internal/validate and cmd/conduit/root/pipelines/validate.go for any
    api.Client/gRPC dial — none exists; the only grpc import is codes (for classification
    constants), not a client.
  • Checked for a nil-pointer risk in ValidateCommand.Render (c.renderer is set in
    ExecuteWithResult, called before Render by the same decorator) — unreachable in the real
    command pipeline, but hardened with a defensive fallback anyway in case a future test calls
    Render directly.
  • Fixed a minor UX rough edge caught while manually verifying --json output: findings was
    serializing as JSON null (not []) for a passing file, which would make a naive --json
    consumer iterating result.files[].findings need to special-case null. Now always [] when
    empty.
  • The cross-file duplicate-ID message initially rendered a Go %v slice ([a.yaml b.yaml]);
    fixed to a comma-joined string before finalizing.

Test plan

  • go build ./...
  • go test ./cmd/conduit/... ./pkg/provisioning/... -race -count=1 — green
  • golangci-lint run ./cmd/... ./pkg/provisioning/... — 0 issues
  • TestValidateFile_ForEachUnwrappedJoin_YieldsAllFindings — proves the ForEach-on-unwrapped-Join
    invariant both ways (raw Join → all findings; re-wrapped Join → collapses to 1, documenting
    exactly the bug this guards against)
  • TestRun_CrossFileDuplicateID / TestRun_SameFileDuplicateID — two files, and two documents
    in one file, sharing a pipeline ID both get the dup-ID code
  • TestRun_Offline_NoDial + structural grep confirmation — no API client anywhere in the call
    graph
  • TestValidateCommand_FileWithErrors_JSON — all 5 findings present in --json, each with
    non-empty code/configPath/message/suggestion, code == a registered conduiterr reason
  • TestValidateCommand_Directory_ShowsPassingFiles / _Quiet_SuppressesPassingLines
  • TestValidateCommand_NonexistentPath_HardFailure — unresolvable path is a HARD failure
    (envelope error set), still classified as exit 2
  • Manual run of the built binary against every fixture in cmd/conduit/internal/validate/testdata/

Tier 2. Roadmap: execution plan §3 (CLI as product). lint/dry-run follow in a second PR per
the design doc's shipping order.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Tapg9dLWXKMZJoL65R24vd

Offline, static verification of pipeline config files: parse -> enrich ->
validate over pkg/provisioning/config, reusing the same machinery
`conduit run`'s provisioning path already uses. Ships as the first of three
verbs (validate|lint|dry-run) sharing one engine
(cmd/conduit/internal/validate); lint and dry-run follow in a later PR.

Per docs/design-documents/20260707-cli-pipeline-validate.md and the shared
20260707-cli-output-conventions.md contract:

- `conduit pipelines validate <path>` (alias `conduit pipeline validate`),
  built on cecdysis.CommandWithResult for the shared --json envelope.
- Collects every finding across every resolved file (no fail-fast); each
  Finding carries code, configPath, message, and suggestion.
- Directory input aggregates all .yml/.yaml files (not recursed), showing
  passing files too; `-q/--quiet` suppresses passing/progress lines only.
- Exit 0 clean, 2 on any finding, via pkg/conduit/exitcode.

Review-flagged must-fixes applied:
- Walks config.Validate's and the parser's raw cerrors.Join with
  cerrors.ForEach directly, never after an outer "%w" wrap (a re-wrap
  collapses N findings into one — see
  TestValidateFile_ForEachUnwrappedJoin_YieldsAllFindings).
- Cross-file/-document duplicate pipeline ID detection is new engine code
  (checkCrossFileDuplicateIDs), not a reuse of
  provisioning.Service.findDuplicateIDs's bare sentinel — it carries a real
  code (new provisioning.CodePipelineIDDuplicate) and a configPath.
- Populated Suggestion on every pkg/provisioning/config validator
  (fieldError gained a suggestion parameter); it was previously always
  empty.
- Enrich runs before Validate, matching service.go's provisioning order.

Supporting refactor: extracted config.ResolveFiles (+ YAMLFilesInDir,
IsYAMLFile) as the single file/directory resolution shared by
provisioning.Service and the new offline command, replacing
Service's private, duplicate implementation.

Additive change to cecdysis: Outcome gained an ExitErr field so a command
whose Outcome is OK:false (a domain failure, not a hard command failure —
Error stays null in the envelope per CLI output conventions §1) can still
give the process a correctly classified nonzero exit code. This was needed
because CommandWithResultDecorator's success path always returned nil
before this change, which would have made `validate`'s exit code always 0
regardless of findings.

Self-review: re-read the diff for the masked-bug risk the design review
flagged (ForEach over a re-wrapped Join collapsing findings) and added a
dedicated regression test proving both directions. Verified end-to-end with
the built binary (single file, directory, cross-file duplicate ID, --json,
--quiet) to confirm exit codes and rendered output match the acceptance
criteria, not just the unit tests. Confirmed via `go build` grep that no
cmd/conduit/internal/validate or pipelines/validate.go code references an
API client — the offline invariant is structural, not just tested.

Tier 2. Roadmap: execution plan §3 (CLI as product).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tapg9dLWXKMZJoL65R24vd
@devarismeroxa
devarismeroxa requested a review from a team as a code owner July 8, 2026 03:30
@devarismeroxa
devarismeroxa merged commit 24e8536 into main Jul 8, 2026
6 checks passed
@devarismeroxa
devarismeroxa deleted the feat/cli-pipeline-validate branch July 8, 2026 03:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant