Skip to content

Move the packages only probe imports under internal/ - #485

Merged
linyows merged 13 commits into
refactor/structurefrom
refactor/internal-packages
Oct 25, 2026
Merged

linyows merged 13 commits into
refactor/structurefrom
refactor/internal-packages

Conversation

@linyows

@linyows linyows commented Oct 25, 2026 •

Copy link
Copy Markdown
Member

The leaf packages at the module root that only probe itself imports move under internal/, so the compiler says they are not API. While moving, the problems earlier reviews found in them are fixed, each in its own commit.

Moved and removed

Before After Why
jsonutil internal/jsonutil Imported only by the root, expr and the grpc action
mapping internal/mapping Imported only by the built-in actions
safefile internal/safefile Imported only by the root and report
truncate internal/truncate Imported only by actionrpc and expr
envfile internal/envfile Imported only by cmd/probe
oas internal/oas Imported only by cmd/probe and the grpc action
procgroup internal/procgroup Imported only by the root
pb internal/pb The gRPC code between the runner and an action; an action is written against actionrpc.Serve, never pb
binary internal/body It reads a response body as text or writes it to a temporary file
binary.ProcessHttpBody body.TextOrFile Names the choice it makes
binary.IsJSONMediaType body.IsJSON
binary.SaveBinaryToTempFile unexported (saveTemp) Only TextOrFile uses it
mapping.AnyToString scalar.String in internal/scalar jsonutil imported mapping only for it
mapping.HeaderToStringValue removed; mapping.StringMap See below
actions/grpc.NewCoverage protocoverage.New in internal/protocoverage Only probe coverage used it; cmd/probe no longer imports actions/grpc

truncate.Map, MapString and MaxLength named in the review were already gone. mask and actionref stay at the root, as the root's exported API uses them.

The proto keeps package pb, so the service is still pb.Actions on the wire and an action built with an earlier probe talks to this one. The code is regenerated from the moved proto: only the file name the descriptor records (internal/pb/actions.proto) and go_package, now the full import path, change. make code now runs buf generate --path internal/pb/actions.proto, since a bare buf generate also picks up the e2e and test server protos, which define the same messages and already refused to build together. actionrpc.Server.Run and Cleanup keep their *pb parameters; they are the gRPC handlers, and an action reaches them through Serve.

For external actions

External actions such as probe-browser and probe-agent imported mapping. What they use of it is offered by actionrpc, the package an external action is written against, as functions that call the same code, so they read parameters exactly as the built-in actions do. The guide for external actions (docs/content/guide/concepts/actions.md, en and ja) shows them.

Before After
mapping.MapToStructByTags(with, &req) actionrpc.DecodeParams(with, &req)
mapping.StructToMapByTags(v) actionrpc.EncodeResult(v)
mapping.FieldTags(v) actionrpc.ParamNames(v)
mapping.ParseTimeout(v) actionrpc.ParseTimeout(v)

No other package this PR moves is imported by them.

Behavior changes

  • A number or a bool in any map[string]string parameter is passed as its text instead of dropped: the gRPC action's metadata, imap's headers, and HTTP's headers (which already got this from HeaderToStringValue).
  • A nil value in such a map, as YAML reads X-Empty: with nothing after it, is an empty string: an empty header, where HTTP sent <nil> and the others dropped it.
  • A list or a map value in such a map is an error naming the key and the field, such as cannot convert a list to a string for 'x-list' in field 'headers', where HTTP sent its Go text and the others dropped it silently.
  • truncate.String backs off to the start of a rune, so a truncated log of Japanese text no longer ends in a broken byte. It backs off at most three bytes, so bytes that are not UTF-8 are cut where the limit falls rather than all left out.
  • A template whose output passes the expression engine's length cap is cut with truncate.String, so it no longer splits a UTF-8 character either.
  • jsonutil.Decode is Parse with its error put in error_message, which now reads invalid JSON: … rather than mustMarshalJSON error: …. A body of null, which LooksLikeJSON never hands to it, would now be an error_message rather than a nil map. parse_json errors read as before; the prefix moves from jsonutil.Parse to the expression function.
  • scalar.String writes nil as "nil", as AnyToString did; its doc comment now says so.

🤖 Generated with Claude Code

https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42

linyows and others added 8 commits October 25, 2026 22:22
jsonutil, mapping, safefile, truncate, envfile, oas and procgroup are
helpers of the runner, its built-in actions and the CLI; nothing outside
the module imports them, and keeping them at the module root offered
them as API that probe never meant to keep stable. Under internal/ the
compiler says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42
pb is the gRPC code between the runner and an action, and only actionrpc
uses it: an action is written against actionrpc.Serve, never against pb.
The proto keeps its package, so the service is still pb.Actions on the
wire and an action built with an earlier probe talks to this one.

The code is regenerated from the moved proto, which changes only the
file name the descriptor records and its go_package, now the import path
in full. make code generates this one file, as a bare buf generate also
picks up the e2e and test server protos, which define the same messages
and refuse to build together.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42
binary did not say what it does: it reads a response body as text when
its Content-Type names a textual type, and otherwise writes it to a
temporary file. Under internal/body, TextOrFile names that choice in
place of ProcessHttpBody, IsJSON reads as the check of a Content-Type it
is, and the helpers only TextOrFile uses are no longer exported. The
extension of a file now reads the media type the same way the checks do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42
jsonutil imported mapping, the package that fills an action's struct
from its parameters, only for AnyToString, which writes a number or a
bool as text. That is neither package's subject, so it lives on its own
as scalar.String, and jsonutil no longer depends on mapping. What it
writes is unchanged, nil as "nil" included; the doc comment now says so,
and the tests cover the zero values written as nil.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42
Decode and Parse both turned a JSON object or array into a value, each
with its own reading of the input; they differed only in what they do
with an error. Decode now returns what Parse returns and puts the error
in error_message, which no longer calls the decoding "mustMarshalJSON".
Parse sits by Decode rather than among the matchers, and leaves the
parse_json prefix of its errors to the expression function that names
it.

A body of null, which LooksLikeJSON never hands to Decode, is now an
error_message rather than a nil map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42
truncate.String cut s[:maxLen], which splits a multibyte character when
the limit falls inside it, so a long Japanese value in a log or a test
failure ended in a broken byte. The cut now backs off to the start of
the rune, keeping at most maxLen bytes as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42
…ngValue

HeaderToStringValue turned the numbers of a headers parameter into
strings by writing over the caller's map, while EnvToStringValue copies;
and it reached only the field named headers, so the gRPC action's
metadata still lost a value such as x-retry: 3, which a map[string]string
field kept only when it was a string. MapToStructByTags now writes a
string, a bool or a number as its text for any such field, through
mapping.StringMap, and leaves out nil, maps and lists as before.

HTTP reads its headers with StringMap too, so a header of nil is no
longer sent as "<nil>", nor a map as its Go text. With nothing left for
HeaderToStringValue to do, it is gone, and embedded no longer copies its
input to protect it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42
NewCoverage served only probe coverage, yet it lived in the grpc action,
so the CLI imported the action package for it and the action carried an
exported function none of its steps use. It reads a .proto file and the
report, nothing of the action, so it moves to internal/protocoverage as
New, beside the OpenAPI coverage in internal/oas, and cmd/probe no longer
imports actions/grpc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42

@linyows linyows left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Adversarial pass over the diff against refactor/structure. What I checked, and what held:

Wire compatibility (OK). Beyond the file_pb_… → file_internal_pb_… identifier renames, the only changes in the regenerated .pb.go files are the descriptor's file name (pb/actions.proto → internal/pb/actions.proto), the go_package option bytes, and ServiceDesc.Metadata. The proto package is still pb, ServiceName is still pb.Actions, and the full method names are still /pb.Actions/Run and /pb.Actions/Cleanup, with no message or field changes. The file path only matters to the protobuf registry inside one process, and runner and action are separate processes. Nothing in probe resolves its own descriptor by file path; the grpc action's reflection client only talks to the user's target server. So an action built against an older probe still talks to this runner.

Makefile/buf (OK). buf.gen.yaml uses paths=source_relative, so --path internal/pb/actions.proto writes next to the .proto file. Scoping the path also stops a bare buf generate from regenerating actions/grpc/testserver/pb (which is generated by protoc with its own relative source path) and e2e/proto. There are no go:generate directives.

External actions (OK). testdata/*-action and the docs only import actionrpc/actions. Nothing outside the module path refers to the moved packages any more. Server.Run/Cleanup have internal/pb types in their signatures, but actionrpc.Serve users never name them.

Decode/Parse (OK). parse_json errors are unchanged (parse_json: empty string, parse_json: <json err>, parse_json: input is not a JSON object or array). match_json/diff_json don't go through Parse. Every Decode caller except grpc's readResponses is guarded by LooksLikeJSON, and that one gets protojson output or "", so the null → nil-map and "mustMarshalJSON error:" → "invalid JSON:" changes are not reachable in a way that matters. Neither string appears in the docs.

StringMap (OK, one nit). I went through every map[string]string field filled by MapToStructByTags: http headers/cookies, grpc metadata, imap headers, shell env/ready, ssh env. Numbers and bools are now kept instead of dropped (grpc metadata and imap headers gain this too, which is fine). shell.ready is still checked on the raw value by checkReady, and env goes through EnvToStringValue first. HeaderToStringValue only ever touched the headers key, so removing it from embedded/ssh/grpc was a no-op there.

truncate. The … (N more bytes) markers live in internal/dump/detail.go, not in truncate, so their counts are unaffected. The masker runs before truncate.Value in actionrpc, so a cut can't expose a partly masked secret.

Inline below: expr still cuts by bytes; the rune back-off is unbounded on non-UTF-8 input; the module's CHANGELOG has no entry for packages that left the public API; and a minor note on nil headers.

Comment thread expr/expr.go
"github.com/mozership/probe/jsonutil"
"github.com/mozership/probe/truncate"
"github.com/mozership/probe/internal/jsonutil"
"github.com/mozership/probe/internal/truncate"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

should-fix: the template output cap at expr.go:544-546 still does outputStr[:maxStringLength] + truncate.Message(). That is a byte slice, so it can split a multibyte rune — exactly what this PR fixes in truncate.String. It's the same operation, so outputStr = truncate.String(outputStr, maxStringLength) would close the gap and leave one place that cuts text.

Comment thread internal/truncate/truncate.go Outdated
}
return s[:maxLen] + Message()
cut := maxLen
for cut > 0 && !utf8.RuneStart(s[cut]) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

nit: the back-off has no bound. On a string that isn't valid UTF-8 (e.g. Latin-1 or binary text in a logged value, where long runs of 0x80–0xBF bytes are possible), it can walk all the way to 0 and log nothing but the marker. Before, it kept 200 bytes. A valid rune is at most utf8.UTFMax bytes, so stopping after utf8.UTFMax-1 steps (for cut > 0 && maxLen-cut < utf8.UTFMax-1 && !utf8.RuneStart(s[cut])) keeps the fix for real text and keeps the old behaviour for garbage.

Comment thread internal/pb/actions.proto
syntax = "proto3";
package pb;
option go_package = "./pb";
option go_package = "github.com/mozership/probe/internal/pb";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

should-fix (or track for the series): CHANGELOG.md says it records "changes that break code importing probe as a library". Its v1.27.0 entry documents implementing pb.ActionsServer/pb.ActionsClient directly, and names procgroup.Tracker.AfterStop as new API. After this PR pb, procgroup, mapping, jsonutil, truncate, safefile, envfile, oas, binary and grpc.NewCoverage can't be imported at all, and neither this PR nor refactor/structure adds a Breaking Changes entry for that. Fine to do it once at the end of the series, but it needs to land before the release.

Comment thread internal/mapping/value.go Outdated
func StringMap(m map[string]any) map[string]string {
out := make(map[string]string, len(m))
for k, v := range m {
switch v.(type) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

nit: dropping nil is better than the old "<nil>". But YAML X-Empty: (a key with no value) decodes to nil, and a user who writes it most likely means an empty header (e.g. Accept-Encoding: to switch off compression). A list such as Accept: [a, b] now also disappears silently, where it used to be sent as [a b]. Two options: map nil to "", or for http headers return an error on non-scalars so the header isn't lost without a word. Up to you; it isn't a regression.

linyows and others added 5 commits October 25, 2026 22:51
The cut backed off over continuation bytes until it found the start of
a rune, so a value of bytes that are not UTF-8, such as a run of 0x80,
was cut down to nothing but the marker. A rune is at most four bytes,
so the cut now backs off at most three; past that the bytes are not
UTF-8 and are cut where the limit falls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42
A template whose output passed maxStringLength was cut with its own
outputStr[:maxStringLength], which splits a multibyte character when
the limit falls inside one. truncate.String cuts at the start of a rune
and appends the same marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42
StringMap left out nil, lists and maps without a word. YAML reads a
header written as X-Empty: with nothing after it as nil, which means an
empty header, so nil is now the empty string. A list or a map has no
text to stand for it, and dropping it sent a request without a header
the workflow named, so it is now an error that names the key and the
field, such as headers or metadata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42
mapping is internal now, yet external actions such as probe-browser and
probe-agent read their parameters into a struct with its
MapToStructByTags, return their result with StructToMapByTags, list
their params with FieldTags and read a timeout with ParseTimeout.
actionrpc is the package an external action is written against, so it
offers the four as DecodeParams, EncodeResult, ParamNames and
ParseTimeout, each the same function under a name that reads well
there, with the same strict reading of numbers, bools and maps of
strings as the built-in actions. The guide for external actions shows
them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42
…r/internal-packages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6CYBxNCxQyo1rbdg9hQ42
@linyows
linyows merged commit 4f4fc5d into refactor/structure Oct 25, 2026
9 checks passed
@linyows
linyows deleted the refactor/internal-packages branch October 25, 2026 14:24
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