diff --git a/.claude/skills/port-from-upstream/SKILL.md b/.claude/skills/port-from-upstream/SKILL.md new file mode 100644 index 0000000..73bfe58 --- /dev/null +++ b/.claude/skills/port-from-upstream/SKILL.md @@ -0,0 +1,114 @@ +--- +name: port-from-upstream +description: Ports features and bug fixes from upstream cel2sql Go (the canonical source) and cross-references cel2sql4j Java into pycel2sql. Use when syncing upstream changes — the workflow enumerates candidate commits since the last sync, verifies whether each change is already applied (many turn out to be done already), maps Go and Java idioms to Python equivalents, ports the test, and updates relevant docs. +--- + +# Port from Upstream + +pycel2sql is a Python port of `github.com/spandigital/cel2sql` (Go). cel2sql4j is the parallel Java port. Upstream Go ships features and fixes regularly; this skill captures the workflow for syncing them across — and uses cel2sql4j as a cross-reference for "how a non-Go port handled this idiomatically." + +The recent v3.7.0/v3.7.1 sync (PR #8: Spark dialect + observe-fork options + BigQuery COALESCE fix) followed this exact shape. + +## Quick start + +```bash +# 1. Enumerate candidate upstream commits since the last sync. +bash .claude/skills/port-from-upstream/scripts/list_upstream_changes.sh + +# 2. For each candidate: grep pycel2sql first — many fixes are already done. +# See "Pre-port verification" below. + +# 3. For real porting work: read the upstream diff, map Go to Python +# (and cross-reference cel2sql4j), write a test asserting the same +# expected SQL, file the PR. +``` + +## pycel2sql tracks two upstreams + +| Repo | Role | Local path | +|---|---|---| +| `cel2sql` (Go) | Canonical source. The reference behaviour. | `/Users/richardwooding/Code/SPAN/cel2sql` | +| `cel2sql4j` (Java) | Cross-reference. Helpful when Go's idiom doesn't translate cleanly to Python — Java often took the same step pycel2sql will. | `/Users/richardwooding/Code/SPAN/cel2sql4j` | + +Both repos are siblings in the user's workspace. The script `list_upstream_changes.sh` reads from both. If either is missing, the script reports clearly which path is expected. + +## Pre-port verification: always grep first + +A surprising fraction of upstream "fixes" turn out to be already applied in pycel2sql — the original Python port pulled some patches preemptively, or the Python implementation never had the bug. **Don't write code before verifying.** + +Examples from the v3.7.1 backport (PR #8): + +| Upstream commit / fix | Already done in pycel2sql? | +|---|---| +| `getDayOfWeek` modulo correction | Yes — `_visit_timestamp_extract` had the right shape. | +| `EXTRACT(... AT TIME ZONE ...)` syntax | Yes — every dialect's `write_extract` already used the correct form. | +| `ARRAY_LENGTH` wrapped in `COALESCE` | 4 of 5 dialects already correct; only BigQuery needed the fix (and it was a *latent* pycel2sql bug, not an upstream regression — the wrap was added in PR #8). | +| Removal of name-based numeric-cast heuristic | N/A — pycel2sql never had this heuristic. | +| 16 sentinel-error refactor | N/A — pycel2sql uses typed `ConversionError` subclasses with dual-message pattern; intentional, mirrors cel2sql4j's `ConversionException`. | + +Probes for the most common items: + +```bash +# Is X wrapped in COALESCE? +grep -nA4 "write_array_length\|write_json_array_length" \ + src/pycel2sql/dialect/*.py + +# Is "AT TIME ZONE" used (vs just "AT")? +grep -rn "AT TIME ZONE" src/pycel2sql/ + +# Does day-of-week emit the modulo / -1 adjustment? +grep -nA3 "DOW\|getDayOfWeek\|dayofweek" \ + src/pycel2sql/_converter.py src/pycel2sql/dialect/*.py +``` + +Note any "already done" finding in the PR description rather than silently skipping it — the next port can re-use that grep result. + +## Out of scope (mirrors upstream rejections + cel2sql4j divergences) + +These upstream concerns intentionally do **not** port to pycel2sql. If you encounter them, add a note to CLAUDE.md's "Important Conventions" section rather than implementing them: + +- **JDBC / cgo schema providers** — pycel2sql has its own `src/pycel2sql/introspect/` module with a clean Python connection-object interface; runtime Go-style introspection isn't a portable contract. +- **16 sentinel error types** (`ErrUnsupportedExpression`, `ErrInvalidFieldName`, …) — pycel2sql uses typed exception subclasses on top of a single `ConversionError` base with dual `user_message` / `internal_details` (CWE-209 prevention). Idiomatic for Python; mirrors cel2sql4j's same decision. +- **Name-based numeric-cast heuristic** (auto-cast of `score` / `value` / `count` / etc. to numeric) — pycel2sql never had it. Confirm with grep before porting any "removal" commits. +- **Comprehension pattern-matching tightening** — pycel2sql's Lark visitor matches comprehensions structurally and doesn't have the same false-positive surface as the Go AST walker. + +## Workflow per upstream commit + +1. **Read** the upstream diff: `git -C /Users/richardwooding/Code/SPAN/cel2sql show `. +2. **Verify** whether the change is needed (Pre-port section). +3. **Cross-reference cel2sql4j** if the change is non-trivial: `git -C /Users/richardwooding/Code/SPAN/cel2sql4j log --grep=`. If cel2sql4j already ported it, the Java diff is usually a closer template than the Go diff. +4. **Map idioms** from Go (and Java) to Python — see [references/go-to-python-idioms.md](references/go-to-python-idioms.md) and [references/java-to-python-cross-refs.md](references/java-to-python-cross-refs.md). +5. **Port the test** — copy the upstream test case, assert the same generated SQL. Use `tests/conftest.py::ALL_DIALECTS` to parametrize when applicable. +6. **Document** — if behaviour differs from upstream by design (e.g. typed-exception model vs sentinel errors), update `CLAUDE.md` "Important Conventions" so the next porter sees the difference. + +## When to split into multiple PRs + +Big upstream syncs often bundle multiple themes. The v3.7.0+v3.7.1 sync (which became cel2sql Go's PRs #117 and #113 and pycel2sql's PR #8) was a candidate for splitting: + +- **Bug fixes + new options** (BigQuery COALESCE, observe-fork options, format() dispatch). ~500 LOC. +- **New dialect** (Spark). ~1000 LOC. + +PR #8 chose to bundle these into a single PR — that's also a valid choice. The trade-off: + +| Single PR | Multiple PRs | +|---|---| +| Faster to author. Easier to write a coherent PR description. Reviewer sees the full picture. | Smaller diffs, easier to review individually. Each can land independently. Better for partial reverts. | + +The user's preference governs. Default to whatever they've done before (single PR for #8). + +## Verification + +```bash +uv run ruff check src/ tests/ +uv run pytest tests/ --ignore=tests/integration -v +python .claude/skills/skill-authoring/scripts/lint_skill.py .claude/skills/port-from-upstream/ +``` + +## Scripts + +- **Run** `bash .claude/skills/port-from-upstream/scripts/list_upstream_changes.sh []` — lists upstream cel2sql commits and tags, plus cel2sql4j commits in the same time window. Default `` is auto-detected from the most recent "Port" commit on pycel2sql `main`. Falls clearly when either sibling repo isn't checked out at the expected path. Override with `UPSTREAM_REPO=/path` and `CEL2SQL4J_REPO=/path` env vars. + +## References + +- [references/go-to-python-idioms.md](references/go-to-python-idioms.md) — Go-to-Python mapping table (closures, errors, struct-and-interface, generics). +- [references/java-to-python-cross-refs.md](references/java-to-python-cross-refs.md) — places where cel2sql4j's Java solution was a closer template for pycel2sql than the Go original (e.g. per-dialect `writeFormat`, `ConvertOptions` shape). diff --git a/.claude/skills/port-from-upstream/references/go-to-python-idioms.md b/.claude/skills/port-from-upstream/references/go-to-python-idioms.md new file mode 100644 index 0000000..7a022f3 --- /dev/null +++ b/.claude/skills/port-from-upstream/references/go-to-python-idioms.md @@ -0,0 +1,106 @@ +# Go → Python Idioms + +Mapping table for porting upstream cel2sql Go code into pycel2sql Python. Distilled from the actual edits in PR #8 (Spark dialect + observe-fork options + BigQuery COALESCE fix). + +## Contents + +- Closures and callbacks +- Errors and panics +- Interfaces, structs, and ABCs +- Generics +- Strings, bytes, and rune handling +- Maps and sets +- Time and durations +- Reflection and type tags + +## Closures and callbacks + +Go's `func() error` closures correspond directly to Python's `Callable[[], None]` (pycel2sql doesn't use Python's exception-by-return pattern; errors propagate via raised exceptions). + +| Go | Python | Notes | +|---|---|---| +| `func(w *strings.Builder) { ... }` | `lambda: ...` | The shared StringIO buffer (`self._w`) plays the role of `*strings.Builder`. | +| `WriteFunc func() error` | `WriteFunc = Callable[[], None]` | Defined in `dialect/_base.py`. Errors propagate via raised `ConversionError`, not return values. | +| `c.write("FORMAT(")` (Go method on converter) | `self._dialect.write_format(self._w, fmt, write_args)` | Python uses an explicit dialect parameter rather than method receivership; the `Dialect` ABC dispatches polymorphically. | + +## Errors and panics + +Go's sentinel-error pattern (`var ErrFoo = errors.New("foo")` + `errors.Is(err, ErrFoo)`) translates to typed exception subclasses in pycel2sql. + +| Go | Python | +|---|---| +| `var ErrUnsupportedFeature = errors.New("unsupported")` | `class UnsupportedDialectFeatureError(ConversionError): ...` (in `_errors.py`) | +| `return fmt.Errorf("%w: ...", ErrFoo)` | `raise UnsupportedDialectFeatureError(user_msg, internal_msg)` | +| `errors.Is(err, ErrFoo)` | `isinstance(exc, UnsupportedDialectFeatureError)` | +| `panic("invariant violated")` | `raise AssertionError(...)` (rare; pycel2sql avoids panic-style errors) | + +The dual-messaging pattern (`user_message` vs `internal_details`) is intentional — see `_errors.py:ConversionError`. The user-visible message is sanitized to prevent CWE-209 information disclosure; the internal message has the full diagnostic detail. + +Don't port the upstream's 16 sentinel errors literally. pycel2sql uses ~16 typed subclasses on a single base class — the surface is the same; the implementation is more idiomatic for Python. + +## Interfaces, structs, and ABCs + +| Go | Python | +|---|---| +| `type Dialect interface { ... }` | `class Dialect(ABC): @abstractmethod def ...` | +| `type DuckDBDialect struct{}` | `class DuckDBDialect(Dialect): ...` | +| `var _ Dialect = (*DuckDBDialect)(nil)` (compile-time interface check) | `Dialect`'s `@abstractmethod`s are checked at instantiation; `DuckDBDialect()` raises `TypeError: Can't instantiate abstract class` if any are missing. CI's `tests/conftest.py::ALL_DIALECTS` instantiates every dialect. | +| `func (d *DuckDBDialect) WriteX(w, write)` | `def write_x(self, w: StringIO, write: WriteFunc) -> None:` | + +Go method names are `PascalCase`; pycel2sql uses `snake_case`. The mapping is mechanical: `WriteJSONFieldAccess` → `write_json_field_access`. + +Go struct fields (e.g. `c.dialect dialect.Dialect`) become instance attributes (`self._dialect: Dialect`). Underscore prefix denotes "internal" (Python's loose convention). + +## Generics + +Go 1.18+ generics rarely appear in cel2sql. When they do (e.g. typed slices via `[]T`), Python uses `list[T]` or `Sequence[T]`. `TypeVar` is rare in this codebase — most "generic" Go shapes become typed `list` / `dict` annotations. + +## Strings, bytes, and rune handling + +| Go | Python | +|---|---| +| `[]byte` | `bytes` | +| `string` | `str` | +| `strconv.Quote(s)` | `repr(s)` (rough — pycel2sql uses `_utils.escape_like_pattern` and dialect-specific `write_string_literal` for SQL string escaping) | +| `strings.Builder` | `io.StringIO` (via `self._w` on the Converter) | +| `strings.HasPrefix(s, p)` | `s.startswith(p)` | +| `strings.Contains(s, sub)` | `sub in s` | +| Hex byte format `%x` | `bytes.hex()` | + +## Maps and sets + +| Go | Python | +|---|---| +| `map[string]bool{"a": true}` | `set[str] = {"a"}` (or `frozenset` if immutable, e.g. `_json_variables`) | +| `map[string]string` | `dict[str, str]` | +| `for k, v := range m { ... }` | `for k, v in m.items(): ...` | +| `_, ok := m[k]; if ok { ... }` | `if k in m: ...` | + +The `frozenset` distinction matters in pycel2sql: per-call options like `json_variables` accept any iterable but normalise to `frozenset` internally so they're hashable and unmodifiable. + +## Time and durations + +| Go | Python | +|---|---| +| `time.Duration` (nanoseconds) | `datetime.timedelta` (usually unused in Converter — duration parsing happens at the string level, not via timedelta) | +| Go-style duration string `"24h"` | Parsed by `_visit_duration_func`'s pattern matching — see `_converter.py:_parse_duration`. Only h/m/s/ms/us/ns recognised. | + +Note: pycel2sql doesn't parse durations into `timedelta` and re-render — it emits the raw value/unit pair into the SQL `INTERVAL` literal. + +## Reflection and type tags + +| Go | Python | +|---|---| +| `reflect.TypeOf(x)` | `type(x)` | +| Struct tags `\`json:"foo"\`` | `dataclasses.field(metadata=...)` (rare; pycel2sql doesn't serialize) | +| `interface{}` (any value) | `Any` from `typing` | + +Reflection is rare in pycel2sql — if the upstream uses heavy reflection, the port likely needs a different shape (often a `dict` lookup or an explicit `isinstance` chain). + +## Common port shapes + +Three patterns recur: + +1. **New `Dialect` method**: add `@abstractmethod` to `_base.py`, implement in all six dialects, add the `write_args: list[WriteFunc]` callback shape if needed. Mirrors Go's `Dialect.WriteX(w, write_args ...func() error)`. +2. **New `Converter` visit branch**: add an `elif` to `member_dot_arg` (method calls) or `ident_arg` (free functions), add a `_visit_` helper. Mirrors Go's `case "":` in the visitor. +3. **New `ConvertOption`**: add a kwarg to `convert()` / `convert_parameterized()` / `analyze()` in `__init__.py`, thread through `Converter.__init__`, store as `self._`. Mirrors Go's functional options (`func WithX(...) ConvertOption`). diff --git a/.claude/skills/port-from-upstream/references/java-to-python-cross-refs.md b/.claude/skills/port-from-upstream/references/java-to-python-cross-refs.md new file mode 100644 index 0000000..e5517ba --- /dev/null +++ b/.claude/skills/port-from-upstream/references/java-to-python-cross-refs.md @@ -0,0 +1,70 @@ +# Java (cel2sql4j) → Python Cross-References + +When the upstream Go diff doesn't translate cleanly to Python, cel2sql4j often took the same step pycel2sql will. This file lists places where cel2sql4j's Java solution was a closer template for pycel2sql than the Go original. + +## Contents + +- Per-dialect `format()` dispatch +- ConvertOptions shape +- Single-functional-interface lambdas vs Go closures +- Single ConversionException base + dual messaging +- Skipped concerns (Java + Python both diverged from Go) + +## Per-dialect `format()` dispatch + +**Upstream Go** (cel2sql `_visit_format` in `cel2sql.go`) emits SQL `FORMAT(...)` directly because Postgres is the default dialect and FORMAT is correct there. The Go dialect interface didn't grow a `WriteFormat` method until much later. + +**cel2sql4j** (`Dialect.writeFormat` in `dialect/Dialect.java` + per-dialect implementations) added the abstract method when Spark landed — Postgres/BigQuery emit `FORMAT('...', ...)`, SQLite/DuckDB emit `printf('...', ...)`, Spark emits `format_string('...', ...)`, MySQL throws `ConversionException`. + +**pycel2sql** (PR #8) followed the cel2sql4j shape exactly — `Dialect.write_format(w, fmt_string, write_args)` in `dialect/_base.py` plus per-dialect implementations. The `_converter.py:_visit_format` was refactored to call `self._dialect.write_format(...)` instead of writing `FORMAT(` directly. + +When porting any feature where SQL diverges across dialects, cel2sql4j's Java implementation usually shows the per-dialect dispatch shape pycel2sql wants. + +## ConvertOptions shape + +**Upstream Go** uses functional options: `cel2sql.WithJSONVariables(...)`, `cel2sql.WithColumnAliases(...)`, etc. + +**cel2sql4j** uses a builder-style `ConvertOptions` class with `withJsonVariables(String...)`, `withColumnAliases(Map)`, `withParamStartIndex(int)` methods. + +**pycel2sql** uses keyword arguments on `convert()` / `convert_parameterized()` / `analyze()` — `json_variables: set[str] | frozenset[str] | list[str] | None`, `column_aliases: dict[str, str] | None`, `param_start_index: int | None`. Internally normalised to `frozenset` / `dict` and threaded through `Converter.__init__` as `self._json_variables` etc. + +The Java naming (`jsonVariables`, `columnAliases`, `paramStartIndex`) inspired the snake_case Python forms (`json_variables`, `column_aliases`, `param_start_index`) more directly than the Go method names (`WithJSONVariables` etc.). + +## Single-functional-interface lambdas vs Go closures + +**Upstream Go**: `func() error` (closure returning error). + +**cel2sql4j**: `interface SqlWriter { void write() throws ConversionException; }` (single-method functional interface). Java lambdas implementing `SqlWriter` look syntactically identical to Go closures: `() -> visit(child)`. + +**pycel2sql**: `WriteFunc = Callable[[], None]` (Python type alias). Errors propagate via raised exceptions, like Java but unlike Go's return-error convention. + +Net: Java's solution is more similar to Python's than Go's is. When Go uses `func() error`, look at how cel2sql4j wrote the same call site — that's usually the right Python shape. + +## Single `ConversionException` base + dual messaging + +**Upstream Go** has 16 sentinel errors: `ErrUnsupportedExpression`, `ErrInvalidFieldName`, `ErrInvalidRegexPattern`, `ErrUnsupportedDialectFeature`, etc. + +**cel2sql4j** uses one `ConversionException` class with `userMessage` + `internalDetails` fields. Sub-typing happens via the user-visible message; runtime callers do `instanceof ConversionException` and read `userMessage`. + +**pycel2sql** uses ~16 typed subclasses on a single `ConversionError` base — `UnsupportedDialectFeatureError`, `InvalidFieldNameError`, `InvalidRegexPatternError`, etc. The base class has the dual-messaging fields (`user_message` + `internal_details`); subclasses serve mainly as pytest-rich `with pytest.raises(...)` markers. + +This is a **hybrid** — pycel2sql gets cel2sql4j's dual-messaging discipline AND keeps Go's typed-error discrimination. Don't port new sentinel errors as a flat list; add a typed subclass on the base. + +## Skipped concerns (Java + Python both diverged from Go) + +Some upstream features were rejected by cel2sql4j and pycel2sql for the same reason. If you find these in an upstream commit, don't port; just document the skip in CLAUDE.md. + +| Upstream | Why both ports skip | +|---|---| +| JDBC schema providers (Go's `pg/provider.go`, `mysql/provider.go`) | Both Java and Python users construct `Schema` directly; runtime introspection has its own subsystem (Java users do it via app metadata, pycel2sql has `src/pycel2sql/introspect/`). | +| 16 sentinel errors | Both ports use single base + structured detail. | +| Name-based numeric-cast heuristic | Java never had it (was only briefly in upstream Go, removed in cel2sql commit c68ab70f). pycel2sql also never had it. | +| Comprehension pattern-match tightening | Both ports use structurally different visitors that don't have the false-positive surface upstream Go did. | + +## Workflow when consulting cel2sql4j + +1. Identify the upstream Go commit you're porting. +2. `git -C /Users/richardwooding/Code/SPAN/cel2sql4j log --grep=""` — see if cel2sql4j ported it. +3. If it did, read the cel2sql4j commit/PR alongside the Go commit. The Java diff is usually a closer template than the Go diff. +4. Map Java → Python (this is mostly mechanical: `Foo` → `foo`, `withFoo` → `foo` kwarg, `SqlWriter` → `WriteFunc`, `throws ConversionException` → `raise ConversionError-subclass`). +5. Cross-reference the cel2sql4j PR's commit message — the Java port often documented the *why* of a design choice that the Go original left implicit. diff --git a/.claude/skills/port-from-upstream/scripts/list_upstream_changes.sh b/.claude/skills/port-from-upstream/scripts/list_upstream_changes.sh new file mode 100755 index 0000000..e51b833 --- /dev/null +++ b/.claude/skills/port-from-upstream/scripts/list_upstream_changes.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# List candidate upstream commits + recent tags for porting from +# github.com/spandigital/cel2sql (Go, the canonical source) and +# github.com/spandigital/cel2sql4j (Java, the cross-reference port) +# into pycel2sql. +# +# Usage: +# list_upstream_changes.sh [] +# +# If is omitted, the script auto-detects it from the most recent +# commit on pycel2sql whose subject mentions "Port" or "backport" — the +# script extracts an upstream SHA from the commit body if present, otherwise +# falls back to the last 30 commits. +# +# Override paths with: +# UPSTREAM_REPO=/path/to/cel2sql +# CEL2SQL4J_REPO=/path/to/cel2sql4j +# +# The script does NOT modify either repo; it only reads. +set -euo pipefail + +UPSTREAM_REPO="${UPSTREAM_REPO:-/Users/richardwooding/Code/SPAN/cel2sql}" +CEL2SQL4J_REPO="${CEL2SQL4J_REPO:-/Users/richardwooding/Code/SPAN/cel2sql4j}" + +if [ ! -d "$UPSTREAM_REPO/.git" ]; then + echo "Error: upstream cel2sql repo not found at $UPSTREAM_REPO" >&2 + echo "Set UPSTREAM_REPO= if it's checked out elsewhere." >&2 + echo "Or clone it: git clone https://github.com/spandigital/cel2sql $UPSTREAM_REPO" >&2 + exit 2 +fi + +# cel2sql4j is optional but recommended. +HAS_CEL2SQL4J=1 +if [ ! -d "$CEL2SQL4J_REPO/.git" ]; then + echo "Note: cel2sql4j repo not found at $CEL2SQL4J_REPO — cross-reference section will be skipped." >&2 + echo " Set CEL2SQL4J_REPO= or clone https://github.com/spandigital/cel2sql4j to enable." >&2 + HAS_CEL2SQL4J=0 +fi + +# Resolve since-sha: arg, then auto-detect, then fallback. +SINCE="${1:-}" +if [ -z "$SINCE" ]; then + PYCEL2SQL_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)" + if [ -n "$PYCEL2SQL_ROOT" ]; then + SINCE="$(git -C "$PYCEL2SQL_ROOT" log --grep='[Pp]ort\|[Bb]ackport' -1 --pretty=format:%B \ + | grep -oE '[0-9a-f]{7,40}' | head -1 || true)" + fi +fi + +echo "=== Upstream cel2sql Go: $UPSTREAM_REPO ===" +echo + +echo "--- Recent tags (10 newest) ---" +git -C "$UPSTREAM_REPO" tag --sort=-creatordate | head -10 +echo + +if [ -n "$SINCE" ] && git -C "$UPSTREAM_REPO" cat-file -e "$SINCE" 2>/dev/null; then + echo "--- Commits since $SINCE ---" + git -C "$UPSTREAM_REPO" log --oneline "$SINCE..HEAD" + RANGE_SINCE_DATE="$(git -C "$UPSTREAM_REPO" show -s --format=%ct "$SINCE" 2>/dev/null || echo "")" +else + if [ -n "$SINCE" ]; then + echo "Note: '$SINCE' not found in upstream; showing last 30 commits instead." >&2 + else + echo "Note: no since-sha auto-detected from pycel2sql git log; showing last 30 commits." >&2 + fi + echo + echo "--- Last 30 upstream commits ---" + git -C "$UPSTREAM_REPO" log --oneline -30 + RANGE_SINCE_DATE="" +fi +echo + +if [ "$HAS_CEL2SQL4J" = "1" ]; then + echo "=== cel2sql4j Java cross-reference: $CEL2SQL4J_REPO ===" + echo + echo "--- Recent commits (in the same time window) ---" + if [ -n "$RANGE_SINCE_DATE" ]; then + git -C "$CEL2SQL4J_REPO" log --oneline --since="@$RANGE_SINCE_DATE" -50 + else + git -C "$CEL2SQL4J_REPO" log --oneline -30 + fi + echo +fi + +echo "=== Hints ===" +echo "- Read an upstream commit: git -C $UPSTREAM_REPO show " +echo "- Search by keyword: git -C $UPSTREAM_REPO log --grep=" +echo "- Many upstream fixes are already done in pycel2sql — grep first." +echo " See SKILL.md 'Pre-port verification' section." +echo +echo "- For non-trivial features, cross-reference cel2sql4j:" +echo " git -C $CEL2SQL4J_REPO log --grep=" +echo " The Java diff is often a closer template than the Go diff."