Skip to content

Refactor import-input substitution to shared resolver/serializer utility#37455

Merged
pelikhan merged 3 commits into
mainfrom
copilot/refactor-duplicate-serialization-logic
Jun 7, 2026
Merged

Refactor import-input substitution to shared resolver/serializer utility#37455
pelikhan merged 3 commits into
mainfrom
copilot/refactor-duplicate-serialization-logic

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Jun 7, 2026

The issue identified duplicated import-input path resolution and value serialization logic in pkg/parser and pkg/workflow, creating a maintenance divergence risk. This PR consolidates that behavior into one shared implementation and updates both call sites to use it.

  • Shared import-input utility

    • Added pkg/importinpututil/import_input.go with:
      • ResolvePathValue(inputs, path) for one-level dotted lookup (key / key.subkey)
      • FormatResolvedValue(value) for normalization + JSON formatting of slices/maps (including typed variants), scalar formatting, and nil handling contract.
  • Parser consolidation

    • pkg/parser/import_field_extractor.go now delegates:
      • path lookup to importinpututil.ResolvePathValue
      • resolved-value formatting to importinpututil.FormatResolvedValue
    • Removed duplicated local reflection/marshal helpers.
  • Workflow consolidation

    • pkg/workflow/expression_extraction.go now delegates:
      • path lookup to importinpututil.ResolvePathValue
      • marshal formatting path to importinpututil.FormatResolvedValue
    • Preserved workflow-specific fallback behavior for unresolved formatting paths.
  • Coverage for shared behavior

    • Added pkg/importinpututil/import_input_test.go for:
      • top-level and dotted path resolution outcomes
      • scalar, typed slice/map, and nil formatting behavior.
value, ok := importinpututil.ResolvePathValue(importInputs, path)
if !ok {
	return match
}
formatted, ok := importinpututil.FormatResolvedValue(value)
if ok {
	return formatted
}
return fmt.Sprintf("%v", value)

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Refactor value-serialization logic in utility packages Refactor import-input substitution to shared resolver/serializer utility Jun 7, 2026
Copilot AI requested a review from pelikhan June 7, 2026 00:56
@pelikhan pelikhan marked this pull request as ready for review June 7, 2026 01:00
Copilot AI review requested due to automatic review settings June 7, 2026 01:00
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR consolidates duplicated “import-input” path resolution and value formatting logic previously split between pkg/parser and pkg/workflow into a shared utility package, then updates both call sites to use the shared implementation.

Changes:

  • Added pkg/importinpututil with shared ResolvePathValue and FormatResolvedValue helpers plus unit tests.
  • Updated workflow import-input substitution to delegate formatting/path resolution to the shared utility.
  • Updated parser import-input substitution to delegate formatting/path resolution to the shared utility and removed duplicated helpers.
Show a summary per file
File Description
pkg/workflow/expression_extraction.go Switches import-input lookup/formatting to importinpututil during expression substitution.
pkg/parser/import_field_extractor.go Switches import-input lookup/formatting to importinpututil and removes duplicated local helpers.
pkg/importinpututil/import_input.go Introduces shared resolver/formatter implementation for import-input values.
pkg/importinpututil/import_input_test.go Adds tests for shared resolver/formatter behavior.

Copilot's findings

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

  • Files reviewed: 4/4 changed files
  • Comments generated: 3

Comment on lines +75 to +86
func normalizeMap(rv reflect.Value) map[string]any {
keys := make([]string, 0, rv.Len())
for _, key := range rv.MapKeys() {
keys = append(keys, key.String())
}
sort.Strings(keys)
normalized := make(map[string]any, rv.Len())
for _, k := range keys {
normalized[k] = rv.MapIndex(reflect.ValueOf(k)).Interface()
}
return normalized
}
Comment on lines +40 to +41
func TestFormatResolvedValue(t *testing.T) {
tests := []struct {
Comment on lines +52 to +53
{name: "typed map", value: map[string]string{"k": "v"}, want: `{"k":"v"}`, ok: true},
{name: "nil value", value: nil, want: "", ok: false},
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 7, 2026

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 7, 2026

PR Code Quality Reviewer completed the code quality review.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 7, 2026

🧪 Test Quality Sentinel completed test quality analysis.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 7, 2026

Design Decision Gate 🏗️ completed the design decision gate check.

@pelikhan pelikhan merged commit b9d70b5 into main Jun 7, 2026
56 of 58 checks passed
@pelikhan pelikhan deleted the copilot/refactor-duplicate-serialization-logic branch June 7, 2026 01:08
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 7, 2026

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (163 new lines in pkg/) but did not link an Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/37455-shared-import-input-resolver-serializer.md — review and complete it before merging.

🔒 This PR cannot merge until an ADR is linked in the PR body.

📋 What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff (consolidating duplicated import-input resolution/serialization into the new pkg/importinpututil package).
  2. Complete the missing sections — add any context the AI could not infer, refine the decision rationale, and confirm the alternatives reflect what you actually weighed.
  3. Commit the finalized ADR to docs/adr/ on your branch.
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-37455: Extract shared import-input resolver/serializer into pkg/importinpututil

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

❓ Why ADRs Matter

ADRs create a searchable, permanent record of why the codebase looks the way it does. Even for a refactor, capturing why a new dedicated package was chosen over the alternatives (leaving the duplication, or hosting the helpers in an existing package) helps future contributors understand the dependency boundaries.

📋 Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number.

🔒 Blocking: link the completed ADR in the PR body to clear this gate.

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · opus48 281.8K · 84.5 AIC · ⌖ 11.8 AIC ·

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Skills-Based Review 🧠

Applied /zoom-out, /improve-codebase-architecture, and /tdd — no blocking issues, commenting with improvement suggestions.

📋 Key Themes & Highlights

Key Themes

  • Implicit contract coupling (/zoom-out): marshalImportInputValue in expression_extraction.go relies on FormatResolvedValue returning false for nil, but the caller nil-checks value after the call — making the behaviour depend on an undocumented internal detail of the utility.
  • Undocumented false semantics (/improve-codebase-architecture): FormatResolvedValue returns (string, false) for both nil input and json.Marshal failure; the two reasons are indistinguishable and undocumented.
  • Single-use wrapper (/improve-codebase-architecture): resolveImportInputValue in the parser is now a one-line passthrough with a single call site.
  • Test coverage gaps (/tdd): Multi-level dotted paths (e.g. "a.b.c") and the marshalValue failure branch are not tested.

Positive Highlights

  • ✅ Clean deduplication — ~127 lines of identical logic removed across two packages with no behavioral regressions found.
  • normalizeMap preserves key-ordering sort from both originals — behavioral parity maintained.
  • ✅ Tests cover the main happy paths and most failure modes for the new utility.
  • FormatResolvedValue / ResolvePathValue are well-named and match existing domain vocabulary.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 734.5K · 220.3 AIC · ⌖ 13.3 AIC

}
case nil:
if formatted, ok := importinpututil.FormatResolvedValue(value); ok {
return formatted
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/zoom-out] This nil guard is now implicit dead code unless you know FormatResolvedValue returns false for nil — the coupling is invisible.

Because FormatResolvedValue(nil) already returns ("", false), the if value == nil branch is reached only by that one case. If the utility ever changes its nil behavior (e.g., returning ("null", true) for JSON semantics), this guard silently becomes unreachable and the caller behavior diverges.

💡 Suggested refactor

Document the dependency explicitly, or drive the nil case entirely from the utility:

func marshalImportInputValue(value any) string {
	if value == nil {
		return ""
	}
	if formatted, ok := importinpututil.FormatResolvedValue(value); ok {
		return formatted
	}
	return fmt.Sprintf("%v", value)
}

This makes the nil contract owned by the caller rather than delegated implicitly.

{name: "dotted path", path: "config.token", want: "abc", found: true},
{name: "missing top level", path: "missing", found: false},
{name: "missing dotted key", path: "config.missing", found: false},
{name: "dotted non map", path: "bad.token", found: false},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] Multi-level path (e.g. "config.api.key") is not tested — the current behavior silently fails because strings.Cut produces "api.key" as the sub-key, which misses in the map.

This is documented as one-level-only in the doc comment, but a test makes the behavior explicit for future readers and guards against accidental relaxation.

💡 Suggested test case
{name: "two-level path not supported", path: "config.token.nested", found: false},

Add this to the test table to nail down the contract.

value, ok := obj[sub]
return value, ok
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/improve-codebase-architecture] The (string, bool) return conflates two distinct failure modes: nil input and json.Marshal failure. Callers cannot distinguish them, which matters if they want to log errors or handle each case differently.

For now both call sites are fine (they treat both as "use fallback"), but the contract is undocumented.

💡 Suggestion

Amend the doc comment to make the false-case contract explicit:

// FormatResolvedValue formats a resolved import input value for textual
// substitution. Returns (formatted, true) on success. Returns ("", false) when
// value is nil or when JSON marshaling fails — callers should treat false as
// "no formatted output available" and apply their own fallback.
func FormatResolvedValue(value any) (string, bool) {

This avoids future callers misreading false as a hard error.

}
})
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/tdd] marshalValue returns ("", false) on json.Marshal error, but there is no test exercising that branch — it is dead from a coverage perspective.

In practice, standard Go types rarely fail marshaling, but channels and function values do. A test guarding this path makes the contract explicit.

💡 Suggested test case
// Add to TestFormatResolvedValue:
{name: "unmarshalable channel", value: make(chan int), want: "", ok: false},

This explicitly documents and covers the marshal-failure path in marshalValue.

}
return normalized
return importinpututil.ResolvePathValue(inputs, inputPath)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[/improve-codebase-architecture] resolveImportInputValue is now a single-call passthrough with one call site — the indirection adds a hop without adding semantics.

Since this wrapper is only called once (line 1239), you can inline it into resolveImportInputPath to remove the layer:

💡 Inlined version
func resolveImportInputPath(inputs map[string]any, inputPath string) (string, bool) {
	value, ok := importinpututil.ResolvePathValue(inputs, inputPath)
	if !ok {
		return "", false
	}
	return importinpututil.FormatResolvedValue(value)
}

If the wrapper is kept intentionally for future callers, a brief comment stating that intent would help.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Jun 7, 2026

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent (but check fails — see violation below)

Analyzed 2 test(s) across 1 new Go test file: 2 design tests, 0 implementation tests, 1 coding-guideline violation (missing //go:build tag).

📊 Metrics & Test Classification (2 tests analyzed)
Metric Value
New/modified tests analyzed 2
✅ Design tests (behavioral contracts) 2 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 2 (100%)
Duplicate test clusters 0
Test inflation detected No (67 test lines / 86 prod lines ≈ 0.78)
🚨 Coding-guideline violations 1 — missing //go:build tag

Test Classification Details

Test File Classification Issues Detected
TestResolvePathValue pkg/importinpututil/import_input_test.go:5 ✅ Design 5 table rows, 3 error/edge cases; missing build tag
TestFormatResolvedValue pkg/importinpututil/import_input_test.go:40 ✅ Design 7 table rows, nil/false edge case; missing build tag

Language Support

Tests analyzed:

  • 🐹 Go (*_test.go): 2 tests — unit (missing //go:build !integration tag)
⚠️ Flagged Tests — Requires Review (1 issue)

🚨 pkg/importinpututil/import_input_test.go — Missing //go:build tag

Classification: Coding-guideline violation
Issue: The newly added file opens with package importinpututil on line 1. Every *_test.go file in this repository must begin with either //go:build !integration (for unit tests) or //go:build integration (for integration tests). Comparison: pkg/workflow/expression_extraction_test.go and pkg/stringutil/stringutil_test.go both start with //go:build !integration.

What would break if not fixed? Without the tag, go test ./... and filtered test runs (e.g., go test -tags !integration) will not correctly include or exclude this file, which may cause CI inconsistencies.

Suggested fix: Add //go:build !integration as the very first line of the file (before package importinpututil), since these are pure unit tests with no external dependencies.

(go/redacted):build !integration

package importinpututil

Verdict

Check failed. Coding-guideline violation: pkg/importinpututil/import_input_test.go is missing the required //go:build !integration or //go:build integration build tag on line 1. The tests themselves are high quality — both are well-structured table-driven behavioral tests covering error/edge cases.

📖 Understanding Test Classifications

Design Tests (High Value) verify what the system does:

  • Assert on observable outputs, return values, or state changes
  • Cover error paths and boundary conditions
  • Would catch a behavioral regression if deleted
  • Remain valid even after internal refactoring

Implementation Tests (Low Value) verify how the system does it:

  • Assert on internal function calls (mocking internals)
  • Only test the happy path with typical inputs
  • Break during legitimate refactoring even when behavior is correct
  • Give false assurance: they pass even when the system is wrong

Goal: Shift toward tests that describe the system's behavioral contract — the promises it makes to its users and collaborators.

References: §27078652942

🧪 Test quality analysis by Test Quality Sentinel · sonnet46 1.3M · 398.1 AIC · ⌖ 43.4 AIC ·

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

❌ Test Quality Sentinel: Coding-guideline violation detected. pkg/importinpututil/import_input_test.go is missing the required //go:build !integration build tag on line 1. All other *_test.go files in this repo (e.g. pkg/workflow/expression_extraction_test.go, pkg/stringutil/stringutil_test.go) begin with //go:build !integration. The tests themselves are excellent — table-driven, behavioral contracts, good edge-case coverage. Please add //go:build !integration as the first line of the file to unblock this PR.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

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

Refactor correctly deduplicates behaviour across both call sites with no regressions. Three non-blocking concerns worth addressing before this shared utility grows more callers.

### Themes

API design — FormatResolvedValue nil contract (medium)
The public function returns ("", false) for both nil input and json.Marshal failure, conflating two distinct outcomes under a single ok=false signal. Every caller must add a post-call nil check to distinguish them. See inline comment on line 41.

Latent panic in normalizeMap (medium)
rv.MapIndex(reflect.ValueOf(k)).Interface() will panic on a zero reflect.Value if the map has non-string keys. The bug pre-existed in both removed inline copies, but it now lives in a shared public function with no documented precondition. See inline comment on line 83.

Fragile nil guard in marshalImportInputValue (low)
The value == nil check on line 508 of expression_extraction.go is correct but implicitly depends on FormatResolvedValue continuing to return ("", false) — not ("", true) — for nil. Any future change to the nil handling in the shared util would silently break this without a compile error. See inline comment.

Test gaps in new package (low)
No tests for typed-nil asymmetry, multi-level dot paths, or the marshal-failure fallback path. See inline comment on the test file.

Warning

Firewall blocked 1 domain

The following domain was blocked by the firewall during workflow execution:

  • proxy.golang.org

To allow these domains, add them to the network.allowed list in your workflow frontmatter:

network:
  allowed:
    - defaults
    - "proxy.golang.org"

See Network Configuration for more information.

🔎 Code quality review by PR Code Quality Reviewer · sonnet46 422.4K · 8.77 AIC · ⌖ 13.4 AIC

sort.Strings(keys)
normalized := make(map[string]any, rv.Len())
for _, k := range keys {
normalized[k] = rv.MapIndex(reflect.ValueOf(k)).Interface()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Latent panic in normalizeMap with non-string-keyed maps: rv.MapIndex(reflect.ValueOf(k)).Interface() will panic on zero-Value when the map has non-string keys.

💡 Details

When rv has a non-string key type (e.g., map[int]string), key.String() does not return the integer as a decimal string — for non-string reflect.Kinds it returns "<int Value>" or similar. The subsequent rv.MapIndex(reflect.ValueOf(k)) then looks up a string key in an int-keyed map and returns a zero reflect.Value. Calling .Interface() on a zero reflect.Value panics:

panic: reflect: call of reflect.Value.Interface on zero Value

This was a pre-existing bug in both removed inline copies, but those were private package functions. Now it lives in a shared public utility with no documented key-type precondition, making it easy to trigger from new callers.

Suggested guard:

func normalizeMap(rv reflect.Value) map[string]any {
    if rv.Type().Key().Kind() != reflect.String {
        // Non-string keys cannot be round-tripped through string lookup;
        // fall back to JSON default marshaling.
        return nil  // or handle differently
    }
    ...
}

Alternatively, add a doc comment to FormatResolvedValue stating it only handles map[string]any-like maps (matching YAML/JSON decode output).

case map[string]any:
return marshalValue(v)
case nil:
return "", false
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

FormatResolvedValue conflates nil with marshal failure via ok=false: The bool return means two very different things.

💡 Details

There are three possible outcomes when calling FormatResolvedValue:

Scenario Return
Scalar / slice / map formatted successfully ("...", true)
nil input ("", false)
json.Marshal failure ("", false)

Case 2 and 3 are indistinguishable. Callers must add an extra value == nil guard after the call to preserve correct semantics — as marshalImportInputValue does. A naive caller who writes:

formatted, ok := FormatResolvedValue(v)
if !ok {
    // handle error case
}

...will silently swallow explicit null import inputs, treating them as errors.

A less footgun-prone design: return ("", true) for nil (nil → empty string is a deterministic, documented choice), and reserve false exclusively for actual errors (marshal failures). This removes the need for every caller to re-check nil.

If nil-as-absent is intentional semantics (matching current call-site behavior), the doc comment should say so explicitly, and the function should be named accordingly (e.g., FormatResolvedValueOrEmpty) to signal the two-failure-mode contract.

if formatted, ok := importinpututil.FormatResolvedValue(value); ok {
return formatted
}
if value == nil {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fragile nil guard: implicit coupling to FormatResolvedValue's nil contract: This value == nil branch is only reachable because FormatResolvedValue returns ("", false) for nil, not ("", true).

💡 Details

If FormatResolvedValue ever changes nil handling (e.g., to return ("", true) for nil), this check becomes silently unreachable dead code — and the behavior change is invisible at this call site. Conversely, if nil handling is removed from FormatResolvedValue entirely, the fmt.Sprintf("%v", nil) fallback would silently produce "<nil>" in substituted templates instead of "".

The two functions are now tightly coupled through an undocumented nil-as-false contract. Consider either:

  1. Adding a comment here: // value == nil is reachable because FormatResolvedValue returns (""‚ false) for nil rather than (""‚ true)
  2. Or fixing the root cause: make FormatResolvedValue return ("", true) for nil so callers don't need a separate guard.

t.Fatalf("FormatResolvedValue(%#v) = %q, want %q", tt.value, got, tt.want)
}
})
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Missing test coverage for the failure path and edge cases: The test suite covers only the happy path — no test for marshal failure, multi-level dot paths, or typed nils.

💡 Suggested additions

Three important behaviors are unspecified and untested:

  1. Multi-level dot path ("a.b.c") — silently resolves as inputs["a"]["b.c"] (two-level, not three), returning (nil, false). This should be documented and tested, since a future caller might expect recursive traversal.

  2. Marshal failure fallback — when json.Marshal fails (e.g., a chan int value), FormatResolvedValue returns ("", false). Callers rely on this to fall back to fmt.Sprintf. The expected fallback behavior should be tested.

  3. Typed nil(*int)(nil) passed as any does NOT match the case nil branch; it reaches formatReflectiveValue whose default path returns fmt.Sprintf("%v", v) = "<nil>". This asymmetry from untyped nil (→ "") is surprising and should be documented/tested.

// Suggested additions to TestFormatResolvedValue:
{name: "typed nil", value: (*int)(nil), want: "<nil>", ok: true},

// Suggested additions to TestResolvePathValue:
{name: "multi-level dot path", path: "config.token.sub", found: false},

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.

[refactor] Semantic function clustering: consolidate duplicated value-serialization logic and util-package reimplementations

3 participants