Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ebb7f10
docs: add spec-identity-pipeline design plan
ProductOfAmerica Jul 2, 2026
5211baf
fix(pipeline): loud exit codes and deterministic generated output
ProductOfAmerica Jul 2, 2026
2bada20
refactor(codegen): extract legacy method naming into a testable module
ProductOfAmerica Jul 2, 2026
5a4bc04
style(tests): replace dash-substitute punctuation in comments
ProductOfAmerica Jul 2, 2026
6a16ab0
feat(codegen): add api-surface manifest library
ProductOfAmerica Jul 2, 2026
0dbe175
fix(codegen): disambiguate sibling operations differing only by param…
ProductOfAmerica Jul 2, 2026
ca4f0da
feat(codegen): seed the api-surface manifest from committed specs
ProductOfAmerica Jul 2, 2026
542478a
feat(codegen)!: generate method names from the api-surface manifest
ProductOfAmerica Jul 2, 2026
eca60d7
feat(codegen): add spec canonicalization library
ProductOfAmerica Jul 2, 2026
da8d9ee
feat(codegen): add multi-document spec validation and structural merge
ProductOfAmerica Jul 2, 2026
d97f09c
feat(codegen): fetch consumes every portal document per slug
ProductOfAmerica Jul 2, 2026
317e112
chore(specs): canonicalize committed spec key order
ProductOfAmerica Jul 2, 2026
92cc584
ci(sync): classify API changes and gate releases accordingly
ProductOfAmerica Jul 2, 2026
9c7c119
fix(codegen): reconcile platform-family server variants across merged…
ProductOfAmerica Jul 2, 2026
737fa7c
feat(api): expose all portal spec documents (15 previously dropped)
ProductOfAmerica Jul 2, 2026
a4f741b
fix(specs): restore equipment item refs and align products action names
ProductOfAmerica Jul 2, 2026
b0e2d1e
docs: document the identity-keyed pipeline and v2.4.0 changes
ProductOfAmerica Jul 2, 2026
8d4a240
fix(ci,codegen): close silent-failure and output-injection gaps from …
ProductOfAmerica Jul 2, 2026
3cf3db2
Merge origin/main (v2.3.1 sync release) into fix/spec-identity-pipeline
ProductOfAmerica Jul 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 97 additions & 6 deletions .github/workflows/sync-api.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,50 @@ jobs:
- name: Fetch latest API specs
run: pnpm fetch-specs

- name: Redact specs
run: pnpm redact-specs

- name: Fix specs
run: pnpm fix-specs

- name: Generate API servers
run: pnpm generate-api-servers

- name: Generate types
run: pnpm generate-types

- name: Generate SDK
run: pnpm generate-sdk

- name: Classify API changes
id: classify
run: |
CLASSIFICATION=$(jq -r .classification sync-report.json)
# Validate against the closed set before it flows into $GITHUB_OUTPUT
# and later shell steps. sync-report.json is derived from uncontrolled
# portal input; an unexpected value here is a corrupt report, not a
# valid state, so fail loudly rather than propagate it.
case "$CLASSIFICATION" in
benign|additive|breaking) ;;
*) echo "Unexpected classification: $CLASSIFICATION"; exit 1 ;;
esac
ADDED_COUNT=$(jq '.newOperations | length' sync-report.json)

echo "Classification: $CLASSIFICATION"
echo "Added operations: $ADDED_COUNT"

echo "classification=$CLASSIFICATION" >> $GITHUB_OUTPUT
echo "added_count=$ADDED_COUNT" >> $GITHUB_OUTPUT

# Neutralize embedded CR/LF in portal-derived fields (spec, path, name)
# so a newline inside an operation string cannot inject a spurious
# heredoc terminator or a new key=value line into $GITHUB_OUTPUT.
{
echo "added_ops<<EOF"
jq -r '.newOperations[] | "- `" + (.spec | gsub("\\r|\\n"; " ")) + "`: " + .method + " " + (.path | gsub("\\r|\\n"; " ")) + " (as `" + (.name | gsub("\\r|\\n"; " ")) + "`)"' sync-report.json
echo "EOF"
} >> $GITHUB_OUTPUT

- name: Lint fix generated code
run: pnpm lint:fix

Expand All @@ -75,10 +110,11 @@ jobs:
SPEC_CHANGES=$(git diff --name-only -- 'specs/raw/*.yaml' 'specs/fixed/*.yaml' | wc -l)
API_CHANGES=$(git diff --name-only -- 'src/api/*.ts' | wc -l)
TYPE_CHANGES=$(git diff --name-only -- 'src/types/generated/*.ts' | grep -v 'index.ts' | wc -l)
MANIFEST_CHANGES=$(git diff --name-only -- 'scripts/api-surface.yaml' | wc -l)

TOTAL=$((SPEC_CHANGES + API_CHANGES + TYPE_CHANGES))
TOTAL=$((SPEC_CHANGES + API_CHANGES + TYPE_CHANGES + MANIFEST_CHANGES))

echo "Spec changes: $SPEC_CHANGES, API changes: $API_CHANGES, Type changes: $TYPE_CHANGES"
echo "Spec changes: $SPEC_CHANGES, API changes: $API_CHANGES, Type changes: $TYPE_CHANGES, Manifest changes: $MANIFEST_CHANGES"
git status --porcelain

if [[ $TOTAL -gt 0 ]]; then
Expand All @@ -92,6 +128,8 @@ jobs:
- name: Determine version bump
if: steps.changes.outputs.has_changes == 'true' || github.event.inputs.force_release == 'true'
id: version
env:
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
run: |
# Get current version from package.json
CURRENT_VERSION=$(node -p "require('./package.json').version")
Expand All @@ -100,9 +138,18 @@ jobs:
# Parse version components
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"

# Bump patch version
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
# Additive changes bump minor and reset patch to 0; everything else
# (benign spec churn, forced release) keeps the patch-only bump.
# Guarded strictly on the classification value, never on whether
# scripts/api-surface.yaml itself shows a diff. CLASSIFICATION travels
# through step env rather than direct expression interpolation.
if [[ "$CLASSIFICATION" == "additive" ]]; then
NEW_MINOR=$((MINOR + 1))
NEW_VERSION="${MAJOR}.${NEW_MINOR}.0"
else
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
fi

echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
Expand All @@ -115,14 +162,26 @@ jobs:

- name: Update CHANGELOG.md
if: steps.changes.outputs.has_changes == 'true' || github.event.inputs.force_release == 'true'
env:
ADDED_OPS: ${{ steps.classify.outputs.added_ops }}
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
run: |
DATE=$(date +%Y-%m-%d)
VERSION="${{ steps.version.outputs.new_version }}"

# Additive runs render the new operations under their own heading;
# everything else keeps today's plain entry. ADDED_OPS travels
# through env rather than direct expression interpolation below, so
# a backtick or paren in an operation name cannot break the script.
ADDED_BLOCK=""
if [[ "$CLASSIFICATION" == "additive" ]]; then
ADDED_BLOCK=$'### Added\n'"$ADDED_OPS"$'\n\n'
fi

# Create new changelog entry
ENTRY="## [$VERSION] - $DATE

### Changed
${ADDED_BLOCK}### Changed
- Synced with latest John Deere API specifications
"

Expand Down Expand Up @@ -205,12 +264,22 @@ jobs:
# modes are eliminated by letting the tag trigger drive the release.

- name: Summary
env:
ADDED_OPS: ${{ steps.classify.outputs.added_ops }}
CLASSIFICATION: ${{ steps.classify.outputs.classification }}
run: |
if [[ "${{ steps.changes.outputs.has_changes }}" == "true" ]]; then
echo "### ✅ API Sync Complete" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Released version: **v${{ steps.version.outputs.new_version }}**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Classification: **$CLASSIFICATION**" >> $GITHUB_STEP_SUMMARY
if [[ "$CLASSIFICATION" == "additive" ]]; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "New operations:" >> $GITHUB_STEP_SUMMARY
echo "$ADDED_OPS" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
cat << 'SUMMARY_EOF' >> $GITHUB_STEP_SUMMARY
${{ steps.summary.outputs.summary }}
SUMMARY_EOF
Expand All @@ -223,3 +292,25 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "The John Deere API specifications have not changed since the last sync." >> $GITHUB_STEP_SUMMARY
fi

- name: Breaking-change guidance
if: failure()
run: |
CLASSIFICATION=""
if [[ -f sync-report.json ]]; then
CLASSIFICATION=$(jq -r .classification sync-report.json 2>/dev/null || echo "")
fi

if [[ "$CLASSIFICATION" == "breaking" ]]; then
MISSING_OPS=$(jq -r '.missingOperations[] | "- `" + (.spec | gsub("\\r|\\n"; " ")) + "`: " + .method + " " + (.path | gsub("\\r|\\n"; " ")) + " (as `" + (.name | gsub("\\r|\\n"; " ")) + "`)"' sync-report.json 2>/dev/null || echo "")

echo "### Breaking API changes detected" >> $GITHUB_STEP_SUMMARY || true
echo "" >> $GITHUB_STEP_SUMMARY || true
echo "Manifest operations missing upstream:" >> $GITHUB_STEP_SUMMARY || true
echo "" >> $GITHUB_STEP_SUMMARY || true
echo "$MISSING_OPS" >> $GITHUB_STEP_SUMMARY || true
echo "" >> $GITHUB_STEP_SUMMARY || true
echo "Remediation: upstream renamed a path: edit that entry's op: in scripts/api-surface.yaml to keep the name; endpoint removed upstream: delete the entry, a major-version consideration; see the header of scripts/api-surface.yaml" >> $GITHUB_STEP_SUMMARY || true
else
echo "Sync failed. Check the failed step's log above for details." >> $GITHUB_STEP_SUMMARY || true
fi
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,9 @@ bash.exe.stackdump
settings.local.json
.gitnexus
/.agents/skills/gitnexus/

# Machine-readable sync classification output (regenerated per run)
sync-report.json

# Session scratch
.superpowers/
56 changes: 56 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,62 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.4.0] - 2026-07-02

The first live multi-document fetch. John Deere's portal returns more than one
OpenAPI document for several API slugs, and the pre-merge fetch kept only one
document per slug, silently dropping the rest. The pipeline now validates and
merges every document, surfacing 56 operations that were always live but never
generated. The same work hardens codegen against the spec-reordering class of
bug that took the daily sync down in June 2026.

### Added

- **56 operations from portal documents the fetch previously dropped.** Each API
slug can resolve to multiple OpenAPI documents; the multi-document merge now
folds every one of them into the generated spec instead of keeping a single
document. By family:
- **field-operations**: field-operation `measurementTypes` (list, get by type).
- **files**: `fileTransfers` (list, get, list by org, create).
- **flags**: `flagCategories` (list, get, create, update, delete) and
`flagCategoryPreferences` (list, get, update).
- **machine-locations**: machine `breadcrumbs` (list).
- **map-layers**: `mapLayers` and `fileResources` CRUD (get, list, create,
update, delete across both).
- **products**: `chemicals`, `fertilizers`, `dryBlends`, `tankMixes`,
`activeIngredients`, product `companies`, and `documents` (list/get plus the
org-scoped create/update and the associate-to-org / set-overrides actions).
- **webhook**: `eventSubscriptionDelivery` (list, patch).

### Changed

- **Codegen is now hardened against upstream spec reordering.** Public method
names are pinned to operation identity (HTTP method + normalized path) in the
committed `scripts/api-surface.yaml` manifest, so reordering a spec's `paths`
can no longer rebind a published method to a different endpoint. Multi-document
slugs are structurally merged (the primary document pinned by a repo-owned
table so the public type surface never silently renames), raw specs are
canonicalized to a deterministic key order so a portal reorder produces no
diff, and every run is classified benign / additive / breaking to gate
releases (additive auto-cuts a minor; breaking fails for a human to reconcile
the manifest).

### Fixed

- **The June 2026 daily-sync outage (red since 2026-06-24) is eliminated at its
root.** John Deere reordered and split the field-operations spec, which
silently rebound `FieldOperationsApi.get` to a different endpoint because method
names were assigned by document order; the `src/safe` facade then stopped
compiling and the sync died at build. Names are now keyed to operation identity,
so a reorder is inert. (Distinct from the 2026-06-07 equipment build break fixed
in 2.2.0.)
- **Equipment return types restored against a John Deere doc regression.** JD's
2026-07 equipment-doc edit dropped the `values.items` `$ref` from two 200
responses while leaving the target schemas defined, collapsing
`EquipmentApi.get` to `PaginatedResponse<unknown>` and `EquipmentApi.getEquipment`
to `unknown`. A guarded `fix-specs` transform restores the refs while the
schemas exist and no-ops once JD repairs the doc.

## [2.3.1] - 2026-06-19

### Changed
Expand Down
60 changes: 54 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## What this is

Unofficial TypeScript SDK for John Deere Operations Center API. 28 APIs / 146 operations. Mostly generated code: specs
Unofficial TypeScript SDK for John Deere Operations Center API. 28 APIs / 202 operations. Mostly generated code: specs
are pulled from John Deere's portal, patched, and fed through a codegen pipeline into `src/api/*.ts`,
`src/types/generated/*`, `src/api-servers.generated.ts`, and `src/hateoas-map.ts`.

Expand Down Expand Up @@ -37,13 +37,55 @@ runner (`node:test` / `node:assert`), not Jest or Vitest.
### Codegen pipeline (`pnpm generate`)

```
fetch-specs → fix-specs → generate-api-servers → generate-types → generate-sdk
raw/*.yaml fixed/*.yaml api-servers.ts types/generated api/*.ts
fetch-specs → redact-specs → fix-specs → generate-api-servers → generate-types → generate-sdk
raw/*.yaml raw/*.yaml fixed/*.yaml api-servers.ts types/generated api/*.ts
```

Each stage is independently runnable (e.g. `pnpm fix-specs`). Re-run the whole pipeline after pulling new specs; CI's
`sync-api.yml` does this daily and opens a release if diffs appear under `specs/`, `src/api/`, or
`src/types/generated/`.
Each stage is independently runnable (e.g. `pnpm fix-specs`). Re-run the whole pipeline after pulling new specs.

`fetch-specs` validates **every** document the portal returns for a slug. A slug can return more than one
document; when it does, they are structurally merged into a single spec (`mergeSpecDocs` in
`scripts/lib/spec-merge.ts`). The primary document is pinned by the repo-owned `PRIMARY_ENDPOINT_NAME` table
(falling back to the slug-named document, else a loud error); the primary owns the merged `info` and its
committed `components` names, so a portal reorder cannot silently rename the public type surface. The merged (or
single) document is then canonicalized to a deterministic key order, so an upstream reorder of
`paths`/`components` yields a byte-identical raw file instead of a misleading diff. Redaction runs per document
before parse; `redact-specs` stays downstream as an idempotent safety net.

Servers reconciliation is family-aware. Documents that differ only as environment instances or doc defects of
the shared `https://{environment}.deere.com/platform` family resolve to the primary's servers block (`fix-specs`
normalizes it to the templated form downstream); a genuinely different family (a deere host on a non-platform
path, or mixed families) refuses to merge, preserving the per-family host trust boundary.

#### The api-surface manifest (`scripts/api-surface.yaml`)

Committed and version-controlled. It maps each operation's **identity** (HTTP method + normalized path) to the
public method name the generator emits, so an upstream `paths` reorder cannot rebind a published method to a
different endpoint (the failure that took the daily sync red on 2026-06-24: a `paths` reorder rebound
`FieldOperationsApi.get` to a different endpoint). Identity
normalizes every `{param}` to `{_}`, so a param rename (`{orgId}` becomes `{organizationId}`) is absorbed and
the method name is preserved. The one exception is sibling operations that differ **only** by param name
(crop-types declares both `GET /cropTypes/{name}` and `GET /cropTypes/{id}`): those match by exact raw path, so a
rename inside such a set is not absorbed and surfaces as breaking for a human to reconcile. `listAll` never
appears in the manifest; it is always derived from a `list` collection GET. New operations receive deterministic
proposed names (verb plus the capitalized last non-param segment, with a documented tiebreak chain; no
positional counters).

Editing the `name:` values is how you approve an upstream change: repoint an entry's `op:` to a renamed path
(the name, hence the public method, is preserved), or delete an entry to drop a removed endpoint's method (a
major-version decision). A manifest entry with no matching operation fails the sync with per-entry diagnostics
rather than silently dropping the method.

Every run is classified into `sync-report.json` (gitignored) for the workflow:

- **benign**: no new and no missing operations. No release.
- **additive**: new operations, none missing. Minor release; `generate-sdk` rewrites the manifest, folding the
new entries in with their proposed names.
- **breaking**: a manifest entry lost its operation. The sync fails and surfaces the missing operations; a human
reconciles the manifest before any release.

CI's `sync-api.yml` runs the pipeline daily, reads the classification, and bumps accordingly: additive cuts a
minor, benign spec churn a patch; a breaking run does not release.

## Architecture

Expand Down Expand Up @@ -140,6 +182,12 @@ To change any of them, edit the corresponding generator in `scripts/` and re-run

Hand-written core: `src/client.ts`, `src/environment-resolver.ts`, `src/errors.ts`, `src/index.ts`.

`scripts/api-surface.yaml` sits between the two: it is committed and version-controlled, but `generate-sdk`
rewrites it wholesale on additive runs. Only its `name:` values are meant to be hand-edited (the rename/removal
approval point described above); per-entry hand comments do not survive a rewrite. `scripts/canonicalize-specs.ts`
is a rerunnable, idempotent one-off that renormalizes committed `specs/raw/*.yaml` to the key order `fetch-specs`
now emits, so live fetches diff cleanly against them.

## Testing conventions

Tests use `tests/helpers/mock-fetch.ts` for injecting fetch behaviour (JSON success, error, retry-sequence, spy, network
Expand Down
Loading