Skip to content

refactor(spec): address code-quality audit findings for internal/core/spec - #2513

Merged
yohamta0 merged 7 commits into
mainfrom
refactor/spec-quality-audit
Aug 6, 2026
Merged

yohamta0 merged 7 commits into
mainfrom
refactor/spec-quality-audit

Conversation

@yohamta0

@yohamta0 yohamta0 commented Aug 6, 2026 •

Copy link
Copy Markdown
Member

Addresses all 18 findings from a code-quality audit of internal/core/spec. The
package was already clean under go vet and golangci-lint; every finding is a
correctness or maintainability issue those tools do not catch.

Net -85 lines. No behavior change except the four defects fixed below.

Defects fixed

DAG-level llm.tools and llm.web_search were silently dropped. The DAG and
step LLM builders decode the same type, but only the step builder copied these
two fields. A chat step that inherits DAG-level LLM configuration therefore
received no tools and no web search, despite both being part of the documented
root contract — chat/executor.go reads cfg.Tools from exactly that inherited
config.

Artifact-action detection scanned arbitrary data as step syntax. Detection
recursed over the whole intermediate DAG and treated any map key action whose
value began with artifact. as an artifact action. A DAG carrying
params: [action: artifact.write] failed to load with
artifact actions require artifacts.enabled to be true when artifacts were
explicitly disabled. Detection is now scoped to executable roots — mirroring the
existing artifact-output detector — and skips parameter payloads.

Schema lookup reported every failure as "file not found." Permission and
resolution failures were indistinguishable from absence and lost their
errors.Is/errors.As chains. Candidate order and fallback are unchanged; a
candidate that failed for a reason other than absence is now reported as a load
failure carrying only those causes, so absence of earlier candidates cannot mask
it.

Two error causes were erased. DAG env resolution replaced the resolver error
with a sentinel plus the raw expression. expandHomeDir swallowed a
UserHomeDir failure and returned the unexpanded path, which was later resolved
against an unrelated directory — so the eventual read error named a path the
caller never requested.

API surface

LoadOption now configures a single build-options struct. LoadOptions and
BuildOpts held the same eleven fields and were copied field by field on every
load, so each new setting had to be added twice.

Unexported or removed, none of which had callers outside the package:
BuildContext, StepBuildContext, BuildOpts, BuildFlag,
TypedUnionDecodeHook, LoadYAMLWithOpts, LoadBaseConfig,
BuiltinActionNames, StepTypeNames, BuildFlagNone, and the unreachable
ErrInvalidJSONFile sentinel. The base-config file loader and its read helper
are deleted outright — base config loads through loadBaseDefinition.

Structure

Typed field pipeline. DAG and step building selected output fields by name
through reflect.Value.FieldByName, so a renamed field silently dropped its
value and an incompatible builder result could panic in reflect.Set — neither
visible to the compiler. Each stage entry now pairs a builder with a typed
assignment function. The worker selector, the one multi-field case that
justified the reflective escape hatch, is a plain typed function, and the
exported Transformer interface is gone.

Shared declarations parse once. stdout, stderr, and shell each fed
several single-field builders that reparsed the same declaration, so one
malformed value produced several equivalent errors that deduplication could not
collapse.

One reflection visitor. Three artifact detectors each implemented the same
recursive traversal across roughly 200 lines that had to stay synchronized. Each
detector keeps its own root scope and predicates.

One clone helper. cloneAny, cloneHarnessSpecValue, and
cloneKubernetesValue recognized different composite types, so the same decoded
shape was deep-copied on one path and left aliased on another.

Shared step validation. Flat arrays, nested parallel arrays, and map form
repeated the same post-build validation, so a new rule had to land in three
branches and validity could drift by syntax.

Tests

TestBuildStep had grown to ~860 lines dispatching unrelated command, executor,
continue-on, retry, repeat, signal, ID, and precondition cases. Split into six
focused suites; blocks move unchanged, all 71 subtests preserved.

Added regression coverage for the two defects with observable behavior:
DAG-level LLM inheritance, and artifact-action detection over parameter
payloads.

Verification

go build ./..., go test ./internal/core/... ./internal/runtime/..., and
golangci-lint under both GOOS=darwin and GOOS=windows are clean.

Provenance

Findings came from an LLM-orchestrated audit whose adversarial verification pass
confirmed 21 of 21 submitted findings. A 100% confirmation rate means that stage
was not discriminating, so each finding was re-validated against the code before
any change — the two behavioral defects were reproduced first. Two findings were
narrowed as a result: artifact-action scoping follows the existing detector's
root scope rather than a new per-field exclusion list, and LoadBaseConfig is
deleted rather than privatized, since removing its only caller left it dead.


Summary by cubic

Refactors internal/core/spec to replace reflective builders with typed assignments, consolidate load options, and fix four correctness issues found by the audit. No behavior change beyond the listed bug fixes.

  • Bug Fixes

    • DAG-level LLM tools and web_search now inherit correctly into chat steps, including web_search.max_uses.
    • Artifact-action detection is scoped to executable roots and treats params as a payload only where it names a field; no false positives for with.params in steps, handlers, or templates, while steps named params and nested foreach still detect actions correctly.
    • Schema file lookup preserves real error causes instead of always reporting “file not found.”
    • Env resolution and expandHomeDir preserve underlying errors and avoid resolving to the wrong path.
  • Migration

    • Replace LoadYAMLWithOpts with LoadYAML(ctx, data, ...LoadOption); LoadOptions is removed in favor of functional LoadOptions.
    • Use WithParams("..."), WithoutEval(), and SkipSchemaValidation() instead of flags/BuildOpts.
    • Base config: pass via WithBaseConfig(path); LoadBaseConfig is removed.
    • Removed/unexported: BuildContext, StepBuildContext, BuildOpts, BuildFlag, TypedUnionDecodeHook, BuiltinActionNames, StepTypeNames, Transformer, ErrInvalidJSONFile.

Written for commit 2f0f42e. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Loading configuration now uses functional options for names, base configurations, working directories, evaluation, and schema validation.
    • Schema-loading errors now provide clearer details, including underlying causes across candidate paths.
    • Validation coverage has expanded for retry, repeat, continuation, precondition, inheritance, and artifact-related settings.
  • Breaking Changes

    • Several previously public loading, step-type, action-name, and builder APIs are no longer available. Integrations using them must migrate to the supported loading APIs.

Collapse the duplicated public configuration model: LoadOption now
configures the single build-options struct directly, removing the
field-by-field copy between LoadOptions and BuildOpts and the drift risk
that came with it. Unexport the build context, build options, and build
flags, which carried only package-private state and had no callers
outside the package. Drop LoadYAMLWithOpts, LoadBaseConfig,
BuiltinActionNames, StepTypeNames, the unreachable ErrInvalidJSONFile
sentinel, and the unused BuildFlagNone.

Preserve error causes that were previously discarded: DAG env resolution
now wraps the resolver error instead of restating the raw expression, and
base-config reads wrap the underlying I/O error so callers can classify
it. expandHomeDir reports a home-lookup failure rather than silently
resolving a home-relative path against another directory, and the
working-directory fallback drops its unreachable branch.

Copy Tools and WebSearch when building DAG-level LLM configuration. Both
builders decode the same type, but only the step builder copied these two
fields, so a root llm.tools or llm.web_search was accepted and then lost
before a chat step inherited the configuration.

Scope builtin artifact-action detection to executable roots and skip
parameter payloads, so free-form data carrying an "action" key no longer
counts as an artifact action and no longer fails validation when
artifacts are explicitly disabled.
…gnment

The DAG and step build pipelines selected output fields by name through
reflect.Value.FieldByName, so a renamed or misspelled field silently
dropped its value and an incompatible builder result could panic inside
reflect.Set. Neither failure was visible to the compiler.

Each stage entry now pairs a builder with a typed assignment function, so
the field and the built value are checked at compile time. The worker
selector, previously the one multi-field case justifying the reflective
escape hatch, becomes a plain typed function, and the exported
Transformer interface is gone with it.

Parse each shared step declaration once. stdout, stderr, and shell each
fed several single-field builders that reparsed the same declaration, so
one malformed value produced several equivalent errors that error
deduplication could not collapse. One entry per declaration now assigns
every field derived from it and reports a single error.
Three artifact detectors each implemented recursive reflection over the
same configuration graph: pointer and interface unwrapping plus map,
slice, array, and exported-struct traversal, repeated across roughly two
hundred lines that had to stay synchronized. A single visitor now owns
the traversal and supplies map-key and struct-field context to per-search
predicates, which also unifies the map-key and struct-field spellings
each detector previously matched separately. Value() any expansion stays
specific to run-artifacts-dir references, and each detector keeps its own
root scope.

Three recursive clone helpers recognized different composite types, so
the same decoded shape was deep-copied on one path and left aliased on
another. The shared helper now covers the full map and slice union and
backs the harness path; the Kubernetes helper is reduced to a wrapper
that preserves its named config type.

Add regression coverage for DAG-level LLM inheritance and for artifact
action detection over parameter payloads.
…test

Extract the post-build rules every step must satisfy into one helper, and
give each steps syntax its own builder. Flat arrays, nested parallel
arrays, and map form previously repeated the same validation sequence, so
a new rule had to be added in three branches and validity could drift by
syntax. Array entries now normalize into ordered groups that share one
build, validation, and dependency loop; map form keeps its forced names
and deterministic sort in a small adapter; router transformation runs
once on the final list.

Unexport the typed union decode hook, which recognizes unexported
manifest types and had no external construction path.

Remove the base-config file loader and its read helper. Base config is
loaded through loadBaseDefinition; this path had no remaining callers.

Split the step build test, which had grown to roughly 860 lines
dispatching unrelated command, executor, continue-on, retry, repeat,
signal, ID, and precondition cases, into focused suites. The blocks move
unchanged, keeping their tables, parallelism, and assertions.
@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The PR makes builder and loader context types private, migrates loading to functional options, replaces reflective DAG and step transformations with typed callbacks, improves artifact and schema error handling, and expands validation coverage.

Changes

Spec build pipeline

Layer / File(s) Summary
Loader context and functional options
internal/core/spec/builder.go, internal/core/spec/loader.go, internal/core/spec/defaults.go, internal/core/spec/manifest_decoder.go
Build contexts and flags are unexported. Legacy loading APIs are removed. Functional options construct and propagate internal build state. Home expansion and decoder helpers return through updated internal APIs.
Typed DAG transformations and validation
internal/core/spec/dag.go
Reflection-based DAG transformations are replaced with typed callbacks. Artifact detection is scoped to executable fields. Worker selectors and step validation are applied directly. LLM output includes tools and web-search configuration.
Typed step construction and cloning
internal/core/spec/step.go, internal/core/spec/step_types.go, internal/core/spec/step_v2.go, internal/core/spec/kubernetes.go, internal/core/spec/human_task.go
Step transformations assign fields directly. Redirect and shell handling are unified. Nested step construction, executor handling, LLM inheritance, and generic cloning use private contexts and typed helpers.
Parameter, variable, and schema resolution
internal/core/spec/dparams.go, internal/core/spec/dparams_runtime.go, internal/core/spec/params.go, internal/core/spec/variables.go, internal/core/spec/schema.go
Resolution helpers use private contexts and flags. Resolver causes are preserved in validation errors. Schema loading distinguishes missing candidates from blocking failures and joins underlying errors.
Validation and regression coverage
internal/core/spec/*_test.go
Tests use functional load options and private contexts. Dedicated coverage validates step policies, repeat-policy errors, LLM inheritance, artifact detection, loader behavior, schema handling, cloning, and variable resolution.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant LoadYAML
  participant buildContext
  participant buildDAG
  participant buildStep
  participant coreDAG
  Caller->>LoadYAML: Provide YAML and LoadOption values
  LoadYAML->>buildContext: Create internal build context
  buildContext->>buildDAG: Build DAG fields and metadata
  buildDAG->>buildStep: Build typed step transformations
  buildStep->>coreDAG: Assign validated steps
  coreDAG-->>Caller: Return built DAG
Loading

Possibly related PRs

  • dagucloud/dagu#2285: Related value-resolution changes use the same builder, loader, parameter, and variable-resolution paths.
  • dagucloud/dagu#2433: Related LLM-building changes modify the same DAG and step LLM construction paths.
  • dagucloud/dagu#2186: Related compiler transformation changes modify the same DAG and step build pipelines.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.62% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the main refactoring purpose and identifies the affected package.
Description check ✅ Passed The description provides a detailed summary, changes, defect fixes, tests, and verification, but omits the template checklist and related-issues section.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/spec-quality-audit

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
internal/core/spec/dag.go (1)

3207-3246: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Drop the redundant ctx buildContext parameter.

stepBuildContext embeds buildContext, so buildCtx.buildContext supplies the same value. Two context parameters allow the caller to pass mismatched contexts.

♻️ Proposed signature change
 func buildStepsFromArray(
 	buildCtx stepBuildContext,
-	ctx buildContext,
 	entries []any,
 	result *core.DAG,
 	names map[string]struct{},
 	defs *defaults,
 ) ([]core.Step, error) {

Then use buildCtx.buildContext for normalizeStepData and stepGroupFromRaw, and update the call site at Line 3168.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/core/spec/dag.go` around lines 3207 - 3246, Remove the redundant ctx
buildContext parameter from buildStepsFromArray and update its caller
accordingly. Within the function, use buildCtx.buildContext for
normalizeStepData and stepGroupFromRaw so all operations share the embedded
context and callers cannot provide mismatched values.
internal/core/spec/step_types.go (1)

912-942: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Handle map[any]any inside cloneAny.

cloneAny can clone custom step fields copied into stepConfig, but a nested map[any]any value is returned unchanged. That leaves shared nested state between the cloned config and result.ExecutorConfig.Config, so later builder reads/writes to that value can mutate step state. Convert map[any]any to map[string]any and recurse into values before returning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/core/spec/step_types.go` around lines 912 - 942, Update cloneAny to
handle map[any]any values by creating a map[string]any, converting each key to a
string, and recursively cloning each value with cloneAny before returning the
new map. Preserve existing handling for all other supported composite types.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/core/spec/builder_test.go`:
- Around line 4348-4352: Extend the web-search inheritance assertions in this
test to verify that the chat step’s LLM WebSearch configuration preserves the
DAG-level max_uses value of 3. Keep the existing Enabled assertion and assert
the corresponding max-uses field on dag.Steps[0].LLM.WebSearch.

In `@internal/core/spec/loader.go`:
- Around line 371-378: Update applyWorkingDirFallback to recurse through every
LocalDAG attached to dag, applying the manifest file directory only when a DAG’s
WorkingDir is empty while preserving explicit child values; add a Load
regression test covering a multi-document manifest with a child DAG that omits
working_dir.

---

Nitpick comments:
In `@internal/core/spec/dag.go`:
- Around line 3207-3246: Remove the redundant ctx buildContext parameter from
buildStepsFromArray and update its caller accordingly. Within the function, use
buildCtx.buildContext for normalizeStepData and stepGroupFromRaw so all
operations share the embedded context and callers cannot provide mismatched
values.

In `@internal/core/spec/step_types.go`:
- Around line 912-942: Update cloneAny to handle map[any]any values by creating
a map[string]any, converting each key to a string, and recursively cloning each
value with cloneAny before returning the new map. Preserve existing handling for
all other supported composite types.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cc549db3-ba15-4193-87d9-c49a0f9b8350

📥 Commits

Reviewing files that changed from the base of the PR and between c01c18e and 42ad0ce.

📒 Files selected for processing (28)
  • internal/core/spec/builder.go
  • internal/core/spec/builder_test.go
  • internal/core/spec/consts.go
  • internal/core/spec/controller.go
  • internal/core/spec/dag.go
  • internal/core/spec/dag_test.go
  • internal/core/spec/defaults.go
  • internal/core/spec/dparams.go
  • internal/core/spec/dparams_runtime.go
  • internal/core/spec/human_task.go
  • internal/core/spec/kubernetes.go
  • internal/core/spec/llm_test.go
  • internal/core/spec/loader.go
  • internal/core/spec/loader_internal_test.go
  • internal/core/spec/loader_test.go
  • internal/core/spec/manifest_decoder.go
  • internal/core/spec/params.go
  • internal/core/spec/params_test.go
  • internal/core/spec/runtime_env_external_test.go
  • internal/core/spec/schema.go
  • internal/core/spec/schema_test.go
  • internal/core/spec/step.go
  • internal/core/spec/step_outputs_external_test.go
  • internal/core/spec/step_test.go
  • internal/core/spec/step_types.go
  • internal/core/spec/step_v2.go
  • internal/core/spec/variables.go
  • internal/core/spec/variables_test.go
💤 Files with no reviewable changes (1)
  • internal/core/spec/step_v2.go

Comment on lines +4348 to +4352
require.Len(t, dag.Steps, 1)
require.NotNil(t, dag.Steps[0].LLM)
assert.Equal(t, []string{"helper-dag"}, dag.Steps[0].LLM.Tools)
require.NotNil(t, dag.Steps[0].LLM.WebSearch)
assert.True(t, dag.Steps[0].LLM.WebSearch.Enabled)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Test web_search.max_uses inheritance.

The test sets max_uses: 3 at DAG level but does not verify that the chat step receives it. A builder that copies only Enabled passes this test.

Proposed test update
 		require.NotNil(t, dag.Steps[0].LLM.WebSearch)
 		assert.True(t, dag.Steps[0].LLM.WebSearch.Enabled)
+		assert.Equal(t, 3, dag.Steps[0].LLM.WebSearch.MaxUses)

As per coding guidelines, “Add or update tests appropriate to the changed code.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
require.Len(t, dag.Steps, 1)
require.NotNil(t, dag.Steps[0].LLM)
assert.Equal(t, []string{"helper-dag"}, dag.Steps[0].LLM.Tools)
require.NotNil(t, dag.Steps[0].LLM.WebSearch)
assert.True(t, dag.Steps[0].LLM.WebSearch.Enabled)
require.Len(t, dag.Steps, 1)
require.NotNil(t, dag.Steps[0].LLM)
assert.Equal(t, []string{"helper-dag"}, dag.Steps[0].LLM.Tools)
require.NotNil(t, dag.Steps[0].LLM.WebSearch)
assert.True(t, dag.Steps[0].LLM.WebSearch.Enabled)
assert.Equal(t, 3, dag.Steps[0].LLM.WebSearch.MaxUses)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/core/spec/builder_test.go` around lines 4348 - 4352, Extend the
web-search inheritance assertions in this test to verify that the chat step’s
LLM WebSearch configuration preserves the DAG-level max_uses value of 3. Keep
the existing Enabled assertion and assert the corresponding max-uses field on
dag.Steps[0].LLM.WebSearch.

Source: Coding guidelines

Comment thread internal/core/spec/loader.go
…ance

Enabled alone did not distinguish a builder that copies the whole
web-search configuration from one that copies only that flag.
Skipping every map entry named params also skipped a map-form step whose
name is params, because that syntax uses the map key as the step name. A
DAG declaring an artifact action under that name loaded without enabling
artifacts, and the run then failed with no artifact directory.

Scoping detection to executable roots is what fixes the reported case, in
which a DAG-level params payload carrying an action key was treated as an
artifact action; the name-based skip was not needed for it. Remove the
skip, and with it the unused suppression path in the visitor.

A parameter payload passed to a child DAG through with.params still
counts as an artifact action. That predates this change and is unchanged
here.
A params entry is a step name only inside a steps container; anywhere
else it names a field. Detection now walks step declarations in those two
modes rather than matching names without regard to position: container
entries are steps whose keys carry no meaning for the search, and within
a step a params entry is a payload handed to a child DAG while a steps
entry opens a nested container.

This drops the false positives where a payload passed through with.params
counted as an artifact action, in steps, handlers, and custom step and
action templates alike, while still detecting an action declared under
any step name and in nested foreach steps.
@yohamta0
yohamta0 merged commit 4202ff5 into main Aug 6, 2026
23 of 25 checks passed
@yohamta0
yohamta0 deleted the refactor/spec-quality-audit branch August 6, 2026 15:40
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