How Does This Not Exist?
Last July I started a tool that checks whether a package's docs still match its code. Before it can do that, it needs a complete, machine-readable answer to one question: what is this package's public surface. Every export, every signature, every type, in a format a program can walk.
I assumed I'd npm install that answer. The odds usually favor someone having built it already. This time they didn't.
Other ecosystems have this
| Ecosystem | The document | Since |
|---|---|---|
| REST | OpenAPI (Swagger), a JSON model of the API | 2011 |
| GraphQL | introspection, built into the spec | 2015 |
| TypeScript | nothing standard |
The odd one out is the ecosystem with the best type information. The types are right there, checked by a compiler, and then they evaporate when the compiler's process exits. There's no document that says "here is this package's surface", nothing to diff between versions, nothing to hand to an agent or a CI gate.
The document
So the tool grew a spec, and the spec became the project: OpenPKG. One command against an entry point, JSON out. JSON Schema (draft 2020-12) does the type representation, because inventing a type language is how specs die.
{ "exports": [{ "name": "connect", "parameters": [ { "name": "host", "schema": { "type": "string" }, "required": true }, ], }], }
The parts that usually get lost are the ones I care about:
- named types resolve as real references, not inlined blobs or flattened strings
- a literal union like
'json' | 'markdown' | 'html'becomes an honest enum - a destructured parameter (
{ host, port = 5432 }) keeps its names and defaults, because that's the actual calling contract
"Isn't this just TypeDoc?"
TypeDoc has emitted JSON since 2014. Its JSON is a serialization of TypeDoc's internal reflection model: a doc generator's working memory, not an interchange format. Same function, both tools:
TypeDoc --json
reflection model, v0.28
names gone; refs are internal IDs
OpenPKG
json schema, draft 2020-12
names, defaults, resolvable $refs
__namedParameters is not a name any caller typed, and "target": 14 is an index into TypeDoc's own object tree. API Extractor's .api.json is closer in spirit, but it was built for Microsoft's docs-and-review pipeline and it shows. Neither had a neutral home the way OpenAPI got a foundation, which I suspect is why nothing stuck. That's a guess, not a documented fact.
The part I got wrong
The spec was the easy half. My instincts wanted the CLI to ship features: a docs-coverage command, a lint command, a report generator with three output formats. I built them, and in December I deleted about 1,400 lines of them in one commit, "consolidate and reduce cli + api features."
| Before | After |
|---|---|
one coverage command, 542 lines: fetch docs, extract surface, validate, fix, report | spec + a set difference |
lint, report --format md/html/json | list, get, filter |
| the tool decides what's acceptable | the tool returns facts; your CI decides |
The test that named it for me came from Dan Shipper and Claude's essay on agent-native architecture, published the same week: "Tools should be atomic primitives. Features are outcomes achieved by an agent operating in a loop." To change behavior, do you edit prompts or refactor code? A compound command makes you refactor.
What's left is five verbs (list, get, snapshot, diff, docs) and everything the compound commands did is a composition of them. Who composes is changing, and agents are ruthless about tools that decide too much.
Try it
npx @openpkg-ts/cli spec src/index.ts
The spec format is versioned, hosted, and small enough to read in one sitting. What went wrong with the outputs of those five verbs is its own note.