A CI script needs the services, methods, and field numbers from your .proto files, and the
runner has no protoc. A parser from npm reads them, including [(acme.sensitve) = true] with a
letter missing. It keeps the option under the name as written, so the check that every sensitive
field is redacted finds no sensitive field and passes.
What you get
You will end up with a script that lists the services, methods, and field numbers in a .proto
tree and refuses a custom option no extension declares. This is for you if a lint reads your
protobuf files with no compiler installed.
Short answer
Parse each .proto file with a plain IDL parser such as @tabnas/proto or protobufjs to list
services, methods, and field numbers without protoc. A plain parse leaves imports unread and
keeps custom options under whatever name was written, so resolve type names against the imported
files and refuse any option no extension declares. Or let buf build compile a descriptor set and
read it with @bufbuild/protobuf, which resolves both for you.
You will need
Node 22 or later, Go 1.24 or later for the Go route, and a directory of .proto files. Verified
2026-10-21 against Node 22.22.0, Go 1.24.7, @tabnas/proto 0.6.11, @tabnas/parser 0.12.14,
protobufjs 8.8.1, @bufbuild/protobuf 2.16.0, buf 1.74.0, and protoreflect 1.18.0. The sample
reads three files: an invoice service, a Money message in the parent package, and the file that
declares the custom option. It generates no code and never calls protoc.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| buf with @bufbuild/protobuf | CI can run one binary, and you want every import, type, and option resolved | A compiler in the build, and options that stay unread bytes until you pass the registry | No step may run a binary, or the parse has to run inside an editor |
| jhump/protoreflect | A Go tool needs resolved descriptors in its own process, with no binary | A deprecated package, and custom options that read as unset until you re-read them | New Go code, which protocompile serves directly |
| protobufjs | Node code already on protobufjs that wants its imports followed for it | Registering descriptor.proto yourself, and option names it never checks against an extension | An option drives a lint, a redaction rule, or a security check |
| @tabnas/proto | A light parse into JSON shaped like descriptor.proto, from TypeScript or Go | Import resolution and option checks you write yourself, and line numbers only on errors | You want names resolved for you and can run a compiler |
Voxgig maintains tabnas. It is one of four options here, not the recommendation.
A compiler answers every question a lint asks, and it costs you a binary in the build. buf refuses
a misspelled option at its line and column, but its descriptor set hides the option from any
reader that has not loaded the extension. protobufjs follows imports and accepts any option name.
protoreflect compiles inside a Go program and returns custom options as unread bytes. @tabnas/proto
stops at the parse, so resolution is code you own.
List what one file declares
@tabnas/proto turns one file into JSON shaped like FileDescriptorProto, the message a compiler
builds for each file it reads. It ships for TypeScript and as a
Go module that tracks it. Walk the object for services,
methods, and field numbers.
const file = 'billing/v1/invoice.proto'
const fdp = parse(readFileSync(`protos/${file}`, 'utf8'))
console.log(`${file}, package ${fdp.package}`)
for (const s of fdp.service) {
console.log(`service ${s.name}`)
for (const m of s.method) console.log(` rpc ${m.name}(${m.inputType}) returns (${m.outputType})`)
}
for (const m of fdp.messageType) {
console.log(`message ${m.name}`)
for (const f of m.field) {
// A scalar has a type; a message or enum reference has only the name as written.
const type = f.typeName ?? f.type.replace('TYPE_', '').toLowerCase()
const label = f.label === 'LABEL_REPEATED' ? 'repeated ' : ''
const options = Object.entries(f.options ?? {}).map(([k, v]) => `${k} = ${v}`).join(', ')
console.log(` ${f.number} ${f.name} ${label}${type}${options ? ` [${options}]` : ''}`)
}
}
console.log(`imports listed, not read: ${fdp.dependency.join(', ')}`)
node list.mjs
billing/v1/invoice.proto, package billing.v1
service InvoiceService
rpc GetInvoice(GetInvoiceRequest) returns (Invoice)
rpc ListInvoices(ListInvoicesRequest) returns (ListInvoicesResponse)
message GetInvoiceRequest
1 id string
message ListInvoicesRequest
1 customer_id string
2 page_size int32
message ListInvoicesResponse
1 invoices repeated Invoice
message Invoice
1 id string
2 total Money
3 card_number string [(acme.sensitive) = true]
imports listed, not read: acme/annotations.proto, billing/money.proto
The field numbers are all there, but each type is only a string. Money and Invoice are the
names as written, and the two imports are listed without being opened. The parser cannot know that
Money means billing.Money, because the file that says so is one it never read. The option
survives as the key (acme.sensitive), spelled however its author spelled it.
Resolve names against the imports
Type names resolve the way C++ scopes do: the innermost scope first, then each enclosing package,
as the language guide on name resolution
puts it. Custom options need the same treatment. Each one is an extension of
google.protobuf.FieldOptions or a sibling message, declared with a number of its own, usually in
another file.1 The resolver in resolve.mjs reads the imports from one root, applies the scoping rule,
and refuses any option that no visible extension declares or whose value does not fit it.
// Protobuf scoping, as protoc applies it: look the name's first part up in the
// innermost scope, then each enclosing one, and finish the lookup in the first
// scope that holds it. So `billing.Money` written in package acme.billing means
// acme.billing.Money, and fails if that is missing, even though package billing
// declares Money. A leading dot starts the lookup at the root.
export function resolveName(written, scope, seen) {
if (written.startsWith('.')) return seen.has(written.slice(1)) ? written.slice(1) : null
const first = written.split('.')[0]
const parts = scope.split('.').filter(Boolean)
for (let i = parts.length; i >= 0; i--) {
const outer = parts.slice(0, i).join('.')
const hit = seen.get(qualify(outer, first))
// A bare name passes over a package, which is never a type or an option,
// and a dotted one over an extension, which holds no names.
if (!hit || hit.kind === (first === written ? 'package' : 'extension')) continue
return seen.has(qualify(outer, written)) ? qualify(outer, written) : null
}
return null
}
// What a value of each scalar option type parses to. An enum value arrives as
// its name and a message value as text, so neither is checked here.
const fits = (ext, value) =>
ext.typeName ? true
: ext.type === 'TYPE_BOOL' ? typeof value === 'boolean'
: ext.type === 'TYPE_STRING' || ext.type === 'TYPE_BYTES' ? typeof value === 'string'
: typeof value === 'number' || /^-?(inf|nan|0x[\da-f]+)$/i.test(value)
// A custom option is a name in parentheses, kept by the parser as written.
// Refuse one that no visible extension of the right options message declares,
// and a value of the wrong type: `= "true"` on a bool option is a string.
function customOptions(options, kind, scope, where, seen, problems) {
const found = {}
for (const [key, value] of Object.entries(options ?? {})) {
const m = /^\((\.?[\w.]+)\)/.exec(key)
if (!m) continue
const full = resolveName(m[1], scope, seen)
const ext = full && seen.get(full)
if (!ext || ext.kind !== 'extension') {
problems.push(`${where}: option (${m[1]}) names no extension`)
} else if (ext.decl.extendee.replace(/^\./, '') !== OPTIONS_OF[kind]) {
problems.push(`${where}: (${m[1]}) extends ${ext.decl.extendee}, not ${OPTIONS_OF[kind]}`)
} else if (!fits(ext.decl, value)) {
problems.push(`${where}: (${m[1]}) takes a ${ext.decl.type.slice(5).toLowerCase()}, not ${JSON.stringify(value)}`)
} else {
found[full] = value
}
}
return found
}
protobufjs walks the imports itself through
Root.resolvePath, once
descriptor.proto is registered. The package ships a copy of that file and leaves it
unregistered,2 so an import of it fails with ENOENT until you register it.
// protobufjs bundles descriptor.proto as JSON but does not register it, so an
// import of google/protobuf/descriptor.proto fails until you do.
protobuf.common('google/protobuf/descriptor.proto', require('protobufjs/google/protobuf/descriptor.json'))
node compare.mjs
@tabnas/proto 0.6.11 | protobufjs 8.8.1 | @bufbuild/protobuf 2.16.0 | buf 1.74.0
route Invoice.total sensitive fields
@tabnas/proto parse Money Invoice.card_number
@tabnas/proto + resolve.mjs billing.Money Invoice.card_number
protobufjs Root.load billing.Money Invoice.card_number
buf + @bufbuild/protobuf billing.Money Invoice.card_number
The three resolving routes agree on billing.Money and on the one sensitive field. The plain parse
prints Money, which is right as text and not yet a type.
Compile a descriptor set with buf
buf build compiles the tree with Buf’s own compiler and writes an image, which its reference
calls wire compatible with a FileDescriptorSet. createFileRegistry in @bufbuild/protobuf
indexes the set so you
can look up any message, service, or extension by its full name.
// A custom option is an extension, read with the extension's own descriptor.
const sensitive = registry.getExtension('acme.sensitive')
const file = registry.getFile('billing/v1/invoice.proto')
const invoice = registry.getMessage('billing.v1.Invoice')
for (const f of invoice.fields) {
const type = f.message ? f.message.typeName : ScalarType[f.scalar].toLowerCase()
const marked = f.proto.options && getExtension(f.proto.options, sensitive) ? ' (acme.sensitive)' : ''
// Positions survive too. A field's path is [4, message index, 2, field index]:
// message_type is field 4 of a file, field is field 2 of a message.
const path = [4, file.proto.messageType.indexOf(invoice.proto), 2, invoice.proto.field.indexOf(f.proto)].join()
const { span } = file.proto.sourceCodeInfo.location.find((l) => l.path.join() === path)
console.log(`${f.number} ${f.name} ${type}${marked}, line ${span[0] + 1} column ${span[1] + 1}`)
}
// Until something names the extension, it is bytes the options message cannot
// read, and converting the options to JSON without the registry drops it.
const options = invoice.fields.find((f) => f.name === 'card_number').proto.options
console.log(`card_number options as JSON: ${JSON.stringify(toJson(FieldOptionsSchema, options))}`)
console.log(`the same, given the registry: ${JSON.stringify(toJson(FieldOptionsSchema, options, { registry }))}`)
npx buf build protos -o descriptors.binpb && node descriptors.mjs
4 files: google/protobuf/descriptor.proto, acme/annotations.proto, billing/money.proto, billing/v1/invoice.proto
rpc GetInvoice(billing.v1.GetInvoiceRequest) returns (billing.v1.Invoice)
rpc ListInvoices(billing.v1.ListInvoicesRequest) returns (billing.v1.ListInvoicesResponse)
1 id string, line 27 column 3
2 total billing.Money, line 28 column 3
3 card_number string (acme.sensitive), line 29 column 3
card_number options as JSON: {}
the same, given the registry: {"[acme.sensitive]":true}
Every name arrives resolved. The set also carries the imports and the line and column of each
declaration, which protoc leaves out unless asked.3 The last two lines are the trap on this
route. The extensions guide says
extensions are stored as unknown fields, so toJson without the registry prints {}.
Read the same tree from Go
protoreflect’s protoparse compiles inside the Go program, so no binary is involved. Its
documentation marks it deprecated in favor of
bufbuild/protocompile, which it wraps.
Reading a custom option takes one extra step.
// optionValue reads a custom option from a field. The parser has checked the
// option against its extension, but this program has no generated code for
// that extension, so the options message carries it as unknown bytes and
// proto.GetExtension reports the default. Re-read the bytes with a resolver
// that knows the extension, then ask.
func optionValue(field, ext *desc.FieldDescriptor) (any, error) {
xt := dynamicpb.NewExtensionType(ext.UnwrapField())
types := new(protoregistry.Types)
if err := types.RegisterExtension(xt); err != nil {
return nil, err
}
raw, err := proto.Marshal(field.GetFieldOptions())
if err != nil {
return nil, err
}
opts := &descriptorpb.FieldOptions{}
if err := (proto.UnmarshalOptions{Resolver: types}).Unmarshal(raw, opts); err != nil {
return nil, err
}
return proto.GetExtension(opts, xt), nil
}
go run .
rpc GetInvoice(billing.v1.GetInvoiceRequest) returns (billing.v1.Invoice)
rpc ListInvoices(billing.v1.ListInvoicesRequest) returns (billing.v1.ListInvoicesResponse)
Invoice.total is billing.Money
(acme.sensitive) read straight from the options: false
(acme.sensitive) re-read with the extension: true
card_number is declared at line 29 column 3
misspelled option: billing/v1/invoice.proto:29:27: field billing.v1.Invoice.card_number: unknown extension acme.sensitve
The parser checked (acme.sensitive) against its declaration, yet the plain read returns false.
proto.Unmarshal looks extensions up in the global registry unless it is
given a resolver. Generated
code registers its extensions there, and a descriptor parsed at run time does not, so the option
stays unknown bytes until optionValue re-reads them. The last line is the compiler refusing the
misspelling, one column earlier than buf placed it.
Check it worked
read.test.mjs runs the three resolving routes over protos/ and expects billing.Money and
Invoice.card_number from each. Then it breaks the tree the ways buf refuses: a misspelled option
wherever an option can sit, a bool option set to a string, and a message option set on a field. It
also tries a missing import, an import cycle, a path outside the root, and a name two files declare.
node --test read.test.mjs
1..16
# tests 16
# suites 0
# pass 16
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 2132.912266
Sixteen tests pass. One of them asserts the gap rather than hiding it: the plain parse and protobufjs accept the misspelled option and report no sensitive field. The last pins the JSON shape the plain parse shares with a compiled descriptor.
When it goes wrong
A lint that keys on (acme.sensitive) reports no sensitive fields after an edit. The option name
was misspelled, and a plain parser keeps any name in parentheses as written. Check every option
name against the extensions the imports declare, or compile the tree.
node pitfall.mjs
Invoice.card_number written as [(acme.sensitve) = true]
@tabnas/proto parse accepted, sensitive fields: none
@tabnas/proto + resolve.mjs refused: billing/v1/invoice.proto: billing.v1.Invoice.card_number: option (acme.sensitve) names no extension
protobufjs Root.load accepted, sensitive fields: none
buf + @bufbuild/protobuf refused: billing/v1/invoice.proto:29:28:cannot find `acme.sensitve` in this scope
Two routes refuse the file and name the field or the line. The other two accept it, which leaves every rule that depends on the option with nothing to apply to.
An option you can see in the file reads as unset. On a compiled route the extension is held as
unknown fields, so proto.GetExtension returns the default in Go and toJson leaves it out in
TypeScript. Re-read the options with a resolver that knows the extension, or pass the registry.
protobufjs throws unresolvable extensions from resolveAll(). A file declares
extend google.protobuf.FieldOptions, and the message it extends was never loaded. Register the
bundled JSON under its full path, google/protobuf/descriptor.proto, because the short name
descriptor wraps the JSON in google.protobuf a second time.
When not to do this
Do not parse .proto text in every script when your build can produce a descriptor set. One
buf build in CI gives each consumer the same resolved tree, and each script then reads names
instead of writing its own scoping rules. The resolver here covers direct imports and protoc’s
scoping rule. It skips import public, reads a built-in file such as google/protobuf/timestamp.proto
only from a copy under the root, and checks no field numbers, so a reused or reserved number passes.
Do not run an option-driven check on a plain parse. @tabnas/proto and protobufjs both keep an
option under any name, so one dropped letter turns the check into one that passes on nothing.
Reach for tabnas where a compiler cannot run, such as a browser or an editor extension, or where
a Go tool and a TypeScript tool should share one grammar. Its JSON follows descriptor.proto, so
a walker written against it can read a compiled set’s JSON too. There, names arrive fully
qualified, fields gain type and jsonName, and options are keyed by extension in brackets. A
string value arrives with its escapes decoded there, where the plain parse keeps them as written.
Do not start new Go code on protoparse. Use protocompile, which it wraps.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, Go 1.24.7, @tabnas/proto 0.6.11, @tabnas/parser
0.12.14, protobufjs 8.8.1, @bufbuild/protobuf 2.16.0, buf 1.74.0, protoreflect 1.18.0, and
google.golang.org/protobuf 1.36.11. Every output block is what the command preceding it printed.
protoreflect 1.18.1 and later declare Go 1.25 and its v2 module has no protoparse, so the sample
pins 1.18.0. @tabnas/proto declares Node 24 in its engines field; npm warned on Node 22 and every
command ran. protoc itself was not run: the defaults in the third note are as Buf’s documentation
states them.
Footnotes
-
Custom option numbers are handed out by a Markdown file. The Protobuf Global Extension Registry lives in the protobuf repository, and a project claims a number by sending a pull request to it. The list opens with the C# port of protocol buffers at 1000 and held 120 entries when this page was verified. The language guide sets 50000 to 99999 aside for use inside an organization, which is how
acme.sensitivecame to hold 50001 without asking anyone. ↩︎ Back to text -
The protobufjs source gives the reason in a comment that reads “Not provided because of limited use” and suggests you discuss it or provide the file yourself. The file declares
FieldOptionsand the other messages a custom option extends, so every.protofile that declares an option imports it. ↩︎ Back to text -
The Buf documentation on images records the defaults: a set written by
protoc -oholds only the files named on the command line, with no source information, until--include_importsand--include_source_infoask for more.buf buildmakes the opposite choice and includes both unless told to exclude them. ↩︎ Back to text