Skip to content

feat(pxq): strict-mode AST validator - #40

Merged
trendvidia merged 1 commit into
mainfrom
feat/pxq-impl-e-strict
May 14, 2026
Merged

feat(pxq): strict-mode AST validator#40
trendvidia merged 1 commit into
mainfrom
feat/pxq-impl-e-strict

Conversation

@trendvidia

Copy link
Copy Markdown
Owner

Summary

Closes the last item from the original pxq design doc. When a
top-level message type is bound to the document — via -m, an @type
directive, or one of the four @proto shapes — the parsed gojq query
AST is walked before compile, and any direct field-chain access that
doesn't resolve against the schema is rejected with a did-you-mean
hint.

Demo

$ pxq -p trades.proto -m trades.v1.Trade '.symbool' march.csv
pxq: strict-mode: unknown field "symbool" on trades.v1.Trade (did you mean "symbol"?)

$ pxq '.inner.t' nested.pxf    # @type bound from the input
pxq: strict-mode: unknown field "t" on strict.v1.Inner (did you mean "s"?)

$ pxq --strict '.x' loose-file.pxf
pxq: --strict requires a top-level message type (pass -m fully.qualified.Name,
set @type in the document, or run `pxq infer-schema` first)

New CLI flags

Flag Effect
-m, --message <FQN> Bind the document root to a fully-qualified message name; required for --strict when no @type is present
--strict Force strict mode; errors when no root type is bound
--loose Force loose mode (skip validator) even when bound

--strict and --loose are mutually exclusive.

What the validator catches (and what it doesn't)

The contract is "no false positives, some false negatives": a query
that passes validation is guaranteed not to type-error on the field
names the validator can see; dynamic access patterns fall back to
gojq's runtime behaviour.

Validated:

  • Direct field chains rooted at identity (.foo, .foo.bar, …)
  • Both halves of a | pipe
  • Nested submessage descents (Outer.inner.s)

Not validated (passes through to runtime):

  • Array indexing and iteration (.tags[0], .tags[])
  • Function calls (pxf_directive(...), length, …)
  • Object/array constructors ({a: .x}, [.x, .y])
  • Comparison type-checking (.age > "30" where age is int32) — out
    of scope; would need a full type inferer

Resolution rules

effectiveMode × resolveRootType encode the README's behavior:

  • Auto-mode: bound → strict, unbound → loose
  • --strict + unbound → actionable error pointing at -m / @type /
    pxq infer-schema
  • --loose: always loose, regardless of binding
  • --message wins over @type; @type falls back to nothing

Test plan

  • go build ./... clean
  • go test ./cmd/pxq — 106 tests pass (86 from prior + 20 new):
    • effectiveMode × {auto, strict, loose} × {bound, unbound}
    • Validator: known/unknown direct fields, did-you-mean radius
      cutoff, nested chains, chain-past-scalar permissiveness, pipe
      halves, non-path Terms permissive, nil-root no-op,
      array-index-then-field permissive
    • resolveRootType: --message-wins, @type fallback, no-binding
      nil, no-schema nil
    • End-to-end through runQuery: strict rejects at compile time,
      loose runs typo to null
  • CLI smoke tests for the four observable behaviors
  • README updated to describe the bound/unbound semantic + the
    three new flags

Future work (not in scope)

  • Comparison type-checking (.age > "30" where age is int32)
  • Tracking element types through array indexing and pxf_directive
    for transitive validation across more access patterns

This wraps the original pxq design doc — every item the README v1
described now exists in code.

Closes the last item from the original pxq design doc. When a
top-level message type is bound to the document — via -m, an @type
directive, or one of the four @proto shapes — the parsed gojq query
AST is walked before compile, and any direct field-chain access that
doesn't resolve against the schema is rejected at compile time with a
did-you-mean hint.

Three new CLI flags:

  -m, --message  fully-qualified message name binding the document
                 root; required for --strict when no @type is present
      --strict   force strict mode; errors when no root type is bound
      --loose    force loose mode (skip validator) even when bound

The validator (strict.go) walks gojq.Query → Term → Suffix recursively,
tracking the current message descriptor down field chains. Field
lookups go through MessageDescriptor.Fields().ByName; missing
fields surface with a Levenshtein-distance-2 typo hint and the parent
message's fully-qualified name. Dynamic access patterns (array
indexing, pxf_directive(...), function calls, object construction)
pass without validation — Stage E's contract is "no false positives,
some false negatives": a passing query is guaranteed not to type-error
on the field names the validator can see; dynamic patterns fall back
to gojq's runtime behaviour.

effectiveMode/resolveRootType encode the README's resolution rules:
- auto-mode: bound → strict, unbound → loose
- --strict + unbound → actionable error pointing at -m / @type /
  pxq infer-schema
- --loose: always loose, regardless of binding
- --strict / --loose mutually exclusive (caught in pickMode)

Test coverage (20 new, 106 total):
* effectiveMode across the {auto,strict,loose} × {bound,unbound} matrix
* validator: known/unknown direct fields, did-you-mean radius cutoff,
  nested chains (both halves), chain-past-scalar permissiveness,
  pipe-both-halves-validated, non-path Terms (literals, object/array
  ctor, function calls) permissive, nil-root no-op, array-index-then-
  field permissive
* resolveRootType: --message wins over @type, @type fallback, no-binding
  returns nil, no-schema returns nil
* end-to-end via runQuery: strict rejects typo at compile time, loose
  runs typo to null per jq

CLI smoke tests verify:
* @type-driven strict mode kicks in implicitly
* --loose disables validation
* --strict without binding shows the prescribed help
* Nested submessages validate transitively (.inner.s passes, .inner.t
  errors with the parent message name)

Remaining design-doc items: comparison type-checking (.age > "30" where
age is int32). Stage E intentionally scopes to field-name correctness;
type-aware comparisons would need a type inferer covering arithmetic
and operator-overload semantics — out of scope here.
@trendvidia
trendvidia merged commit 2ca3251 into main May 14, 2026
4 checks passed
@trendvidia
trendvidia deleted the feat/pxq-impl-e-strict branch May 14, 2026 06:12
trendvidia added a commit that referenced this pull request Jul 24, 2026
- Implementation boxes #40#43 ticked: protocheck v2.0.0 (2026-07-23)
  shipped the engine SPI (PR#22), function registration + init-time
  verification (PR#23), and validation execution — WithFailFast (PR#21),
  wire-aligned EnrichedViolation model (PR#24), carrier-rule dispatch
  with source-map enrichment (PR#26); catalog + i18n (PR#28, M6).
  v2.1.0 added per-element repeated/map dispatch (PR#35).
- #50#52 ticked: protolsp shipped RFC-001 extended-grammar parsing
  and semantic tokens (protolsp#218 Phases A/B, 2026-07-16), source-map
  navigation with descriptor fallback (protolsp#219 Phase C, #220), and
  annotation-aware diagnostics (#220; completion parity #228,
  runtime-violation overlay #231).
- #70 ticked: protowire-go v1.3.1 (2026-07-23) wires the Validator
  seam through the pxf/pb/sbe decoders (protowire-go#49/PR#59) with the
  protocheck-side adapter (protocheck PR#38); protovalidate adapter as
  nested module (PR#60), noted on #17.
- M0 spec boxes #4#7 ticked (landed via GH #54; annotations.proto
  round-trip verified per GH #58); #6 acceptance boxes ticked against
  the shipped STABILITY.md section.
- #19 annotated: §5.3 executable fixtures shipped (GH #135, PR #138);
  corpus expansion continues in GH #68.

Remaining implementation fronts: #60/#61 (protobuf-go codegen) and
#80 (OpenAPI, GH #93).
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