Skip to content

fix(generate): --services narrows the whole library, not just the facade - #1179

Merged
antoinetoussaint-byte merged 4 commits into
mainfrom
fix/generate-client-services-narrows-all
Oct 26, 2026
Merged

antoinetoussaint-byte merged 4 commits into
mainfrom
fix/generate-client-services-narrows-all

Conversation

@antoinetoussaint-byte

@antoinetoussaint-byte antoinetoussaint-byte commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

What was wrong

codefly generate client --services <S> narrowed only the facade. The library still carried every service of the contract (#1173):

  • contract/contract.binpb;
  • contract/catalog.codefly.json;
  • library.codefly.yaml's sources[].services;
  • every language's bindings.

So a library generated for one saas service carried all 36 of them. That blocks module-saas-starter#1217 (B2).

What changes

GenerateClientLibrary narrows its entries before anything is generated (pkg/generators/narrow.go, narrowEntries).

What the persisted contract keeps:

  • the named services;
  • the declarations those services reach: their methods' messages and, transitively, every message and enum those name (a nested type keeps the top-level message holding it);
  • every extension of a kept message, wherever that extension is declared.

Files kept whole:

  • any file defining a custom option that a kept declaration sets, with its imports. This covers options on any element, including extension ranges, and extensions set inside a message-valued option's value;
  • any shared upstream file (google/protobuf, google/api, google/rpc, google/type, buf/validate), in its canonical shape.

How the result is built:

  • No file declares a service that was not named.
  • Imports are recomputed for each file, including chains of public imports.
  • Source info is re-indexed, so each doc comment stays with its declaration.
  • The narrowed set must resolve (protodesc.NewFiles) before it is used.
  • Narrowing depends only on the contract bytes: options stay raw (no resolver) and marshalling is deterministic.

Digests. The digest that identifies a library's source stays the producer's contract digest:

  • sources[].contract-digest and clients.codefly.json record that same digest.
  • Editing the services list, or adding an entry, regenerates in place. Only a new producer contract needs --force.
  • contract/catalog.codefly.json carries the digest of the narrowed contract.binpb written beside it.
  • Libraries generated before this change keep the same producer digest, so they regenerate without --force.

Outputs:

  • The catalog's services, the manifest's sources[].services, the bindings and the facade in Go, TypeScript and Python all come from the narrowed contract.
  • A service name the contract does not declare is refused. The error names it and lists the services the contract does declare.
  • A local --from module/service with --services now generates from the narrowed descriptor set, because the proto sources declare every service.

Aggregated libraries (sync solution-sdk):

  • Each entry persists its own narrowed contract, which declares only its own selection and matches its catalog.
  • Bindings are generated from the closure of all entries at once, so a file two entries share is generated identically for both.
  • An entry naming no services keeps its whole contract.
  • Two entries carrying different versions of one file are refused, naming the file and both entries.
  • A library where no entry names services is untouched.
  • sync solution-sdk gains the --force flag that its digest refusal tells you to use.

Reuse

  • Reused:
    • checkContractDiamond, which still runs on the producer contracts;
    • the existing descriptor-set generation path (markUpstreamModulesAsImports, targetFilesForPackage);
    • the upstreamProtoModules table, through a language-independent matchUpstreamModule that upstreamModuleFor now calls.
  • Not added: no second generation path and no per-language filtering.

Why not buf's --type filtering. The companion's buf (1.73.0) has buf build <image> --type <name>, which filters one image down to the closure of the named types. I did not use it, for three reasons:

  1. It filters one image at a time. An aggregated library needs a closure computed across several contracts, so that a file shared by name is generated the same for every entry. Separate --type runs would cut a shared file differently for each entry, and the merged tree would keep whichever copy was written last. That is the conflict narrowEntries prevents.
  2. It would tie the library to buf's version. The persisted contract and its digest would then depend on the companion's buf release, so a companion bump could move every committed library. In-process narrowing depends only on the contract bytes.
  3. It needs Docker. Narrowing runs before generation and can be tested without Docker. pkg/generators has no plumbing for running buf in the companion; using it would mean adding a second Docker path.

Size. narrow.go is about 720 lines including comments and doc blocks, over the ~500-line guideline. Review r140's F3–F6 added the extension and nested-option handling.

Classification

Fix, made in the generator that owns what a library contains.

  • Docs: docs/commands.md said --services does not work over a descriptor set. That limitation is gone, because the facade plugins now run with strategy: all, so the paragraph now describes what --services does. docs/cli-reference.md was regenerated.
  • Stale comments: two code comments about a core facade bug, and one test comment, were stale and are removed.

Tests

pkg/generators/narrow_test.go (no Docker):

  • one service keeps exactly what it needs, comments stay with their declarations, and the producer digest is kept;
  • narrowing is stable and idempotent;
  • an unknown service is refused;
  • a library that names no services is untouched;
  • in an aggregated library, each entry persists only its own selection, the shared file is generated once, and adding an entry leaves the other entry's contract unchanged;
  • two versions of one file are refused;
  • extension-range options, extensions of a kept message declared in another file, and extensions inside a message-valued option all keep their defining files (proto2 fixture). Each of these three fixes was mutation-checked: removing it makes the test fail.

cmd/generate/client_services_test.go (Docker-gated):

  • TestGenerateClientServicesNarrowsTheWholeLibrary: generating one service of a real buf-compiled two-service contract in Go, TypeScript and Python carries only that service and what it needs. The manifest records the producer digest. go vet and a Python import both pass. This test fails without the narrowing.
  • TestGenerateClientServicesIsStable: regenerating in place, and generating afresh into another directory, both reproduce the library byte for byte.
  • TestGenerateClientServicesSelectionChangesRegenerateWithoutForce: editing an entry's services, then adding a second entry, both regenerate without --force. Each entry's persisted contract declares only its own service. Both record the producer digest. Regenerating is stable and the bindings import. With the old narrowed-digest behaviour, this test fails with the refusal. It uses Python because one Go package cannot hold two facades of different names; that limit of aggregated Go libraries predates this change.

Evidence

  • CODEFLY_GENERATE_QUALIFY=1 go test -tags proto_companion_required ./cmd/generate/ ./cmd/publish/ ./cmd/sync/ ./pkg/generators/ -count=1 passed locally on proto companion 0.0.18 (ghcr.io/codefly-dev/proto:0.0.18).
  • go test -race ./pkg/generators/ ./cmd/sync/ passed.
  • golangci-lint reports 0 issues on the changed packages.
  • I compiled the generated TypeScript library by hand (npm install && npm run build) at the first head. It built cleanly and mentioned no other service.

Not in this PR

The issue also asks for a --check mode on generate client. That flag is not here; the drift test above is the behaviour such a flag would gate on. sync solution-sdk --check already exists.

Refs #1173

🤖 Generated with Claude Code

antoinetoussaint-byte and others added 4 commits October 25, 2026 23:05
`generate client --services` restricted only the facade: the persisted
descriptor set, the catalog, the manifest's sources[].services and every
language's bindings still carried every service of the contract (#1173).

The library is now narrowed before anything is generated. The contract
keeps the named services and the declarations they reach (their methods'
messages and, transitively, every message and enum those name); a file
defining a custom option they set is kept whole, as is a shared upstream
file; no other service is declared anywhere. Catalog, manifest, bindings
and facade all come from the narrowed contract, and its digest is the one
recorded. A name the contract does not declare is refused by name. An
aggregated library (sync solution-sdk) is narrowed as one, so a file two
entries carry comes out the same for both.

The docs' "--services does not work over a descriptor set" limitation is
gone: the facade plugins run with strategy: all, and the qualified tests
generate a narrowed descriptor set in Go, TypeScript and Python.

Refs #1173 (the narrowing; the issue's --check mode is not part of this change)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rrowing follows extensions (r140)

Review r140 of #1179:

- F1/F7: the entry keeps the producer's contract digest. It is what
  sources[].contract-digest and clients.codefly.json both record, so editing
  an entry's services or adding an entry regenerates without --force; only a
  new producer contract needs it. The catalog under contract/ carries the
  digest of the contract persisted beside it. sync solution-sdk gains the
  --force its refusal names.
- F2: each entry persists its own closure; bindings are generated from the
  library-wide closure, so a shared file is still generated once.
- F3: options on extension ranges are visited.
- F4: every extension of a kept message is kept, wherever it is declared.
- F5: two entries carrying different versions of one file are refused,
  naming the file and both entries.
- F6: extensions set inside message-valued options are followed, through the
  contract's descriptors or the protobuf runtime's.

Refs #1173

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@antoinetoussaint-byte
antoinetoussaint-byte added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit b71a3c4 Oct 26, 2026
14 of 15 checks passed
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