Repository navigation
feat!: forge reads native extension keys only (FORGE-1) - #73
Swimburger wants to merge 7 commits into
Conversation
491beec to
d0ecf1f
Compare
…writes the rest `forge` reads a native `x-forge-*` vocabulary and nothing else. The extensions other tools write (`x-fern-*`, `x-stainless-skip`, `x-sensitive`) are ordinary extensions to it, and a new command, `forge normalize`, rewrites them into the native ones, so the spec that is produced is itself what gets transformed. FORGE-1. The native vocabulary: `x-forge-method-name`, `x-forge-group-name` (a dotted string or an array), `x-forge-skip`, `x-forge-availability`, `x-forge-model-name`, `x-forge-property-name`, `x-forge-parameter-name` and `x-forge-sensitive`, kept by `src/metadata/` per kind of object, alongside `x-forge-hidden`, `x-forge-require-confirmation`, `x-forge-aliases` and `x-forge-commands`. The reader asks it for a key and finds one written twice; there is no fallback and no ranking between keys. `forge normalize [--leave-old-extensions] [--check] [-v] <INPUT>...` takes files, folders (every .json file below them, except hidden ones and those in a node_modules), glob patterns, or - for standard input to standard output, and rewrites them in place. - It edits the bytes of the document and does not write it again, so whitespace, the order of keys and the text of every number stay as they were, and a diff is only the keys. It walks the document once with no tree, and checks it to be JSON on a second thread. - A key is rewritten only at the kind of object `forge` reads its native key at, so it never gets a meaning it did not have. The tables of other tools' keys are `src/normalize/vendor/`, per level, the only code that names them; a boundary test enforces it. - A native key is never replaced: when a document says the same thing twice, the native key is kept, and the other is dropped, or reported if it says something else. - A group path that is an array becomes the names joined by dots, unless a name has a dot in it. What no native key says, such as a skip that is a list of targets, is left as it is written. - What it cannot rewrite, a key written twice or a value that cannot be read, is left and warned about, with where a parser would stop. - Files are replaced by writing a new one beside them and renaming it, or in place where that is not possible, as for a hard link. A file with more than one name is rewritten once, and a link stays a link. - `--check` writes nothing and fails if anything would change. RFC 0001 describes the design, and AGENTS.md says that compatibility with other tools' extensions is sectioned off in `src/normalize/vendor/`. Breaking: a document written with other tools' keys builds only what its native keys say until it has been through `forge normalize`.
…or now Until the release that makes `forge normalize` a step to run first, `forge` runs the same rewrite on the bytes it reads, before it parses them, so a document that has not been through `forge normalize` builds what it did: the released aggregate writes the same 3,243 files of `--target ts`, byte for byte, without the step. FORGE-1. It is temporary, and only the binary does it: the engine in `src/` reads native keys and nothing else. - It is the default rewrite, so the keys it rewrites are not kept, as `forge` did not hand them on to plugins before. - It says nothing of what it leaves, as `forge` said nothing of the document before. A key written twice is an error in the words `forge` used when it read those keys itself, down to the line and column: "duplicate field `x-fern-sdk-method-name` at line 7 column 30". A value that cannot be read is left in place, and `forge` says it as it reads it. - An error `forge` finds later in the document gives the column of the document that was rewritten, which differs from the one that was read where a key was renamed earlier on the same line. The line is the same. Cost, on the 28 MB released aggregate, best of 30: the rewrite is 17.6 ms when it has 11,714 keys to rewrite and 14.4 ms when it has none, next to 41.6 ms to parse into a `Forge`. The whole `--target ts` run goes from about 252 ms to 262 ms for a normalized document and to 270 ms for a raw one, which is 7%.
d0ecf1f to
fe43db8
Compare
srikrsna
left a comment
There was a problem hiding this comment.
Left some comments, I think we can let go of trying to retain whitespace changes, retaining the order is good enough. There is a mini json parser in this code which I would like to avoid. We should also lean on serde to help us support both json and yaml.
| ## What `forge normalize` does | ||
|
|
||
| - A key is rewritten only at the kind of object `forge` reads its native key at: an operation or an alias, a parameter, or a schema. | ||
| - The document is edited as bytes, not written again: whitespace, key order and the text of every number stay as they were, so a diff is only the keys. |
There was a problem hiding this comment.
Do we even retain whitespace?
There was a problem hiding this comment.
Yes, deliberately. This is for teams migrating their own specs, in whatever layout and with whatever comments they have, and they'll commit the result. Editing in place keeps their diff to just the renamed keys. Rewriting from a tree reformats any file that isn't already in serde's layout. Key order is kept either way.
YAML input isn't supported yet, but can be added as a fast follow.
| - The document is edited as bytes, not written again: whitespace, key order and the text of every number stay as they were, so a diff is only the keys. | ||
| - A native key is never replaced. When a document says the same thing twice, the native key is kept, and the other is dropped, or reported if it says something else. | ||
| - A group path that is an array becomes the names joined by dots, unless a name has a dot in it. | ||
| - What no native key says, such as a skip that is a list of targets, is left as it is written. |
There was a problem hiding this comment.
I don't understand this line
There was a problem hiding this comment.
Fair, poorly written. This says that x-stainless-skip: true is converted to x-forge-skip: true, but x-stainless-skip: ['terraform'] doesn't have a native forge equivalent, and is therefore untouched.
| /// have every `.json` file below them rewritten, except those that are | ||
| /// hidden or in a node_modules, or as glob patterns such as | ||
| /// 'specs/**/*.json', which are taken as they are written. Or - to read one from standard input and write | ||
| /// it to standard output. |
There was a problem hiding this comment.
We should not account for node_modules here. They can pass anything they want, glob expansion is a shell concern.
There was a problem hiding this comment.
Not all shells provide good support for that, but we don't have to worry about wider audience UX at this point. We can keep it simple for now.
| - Groups are a dotted string or an array, so a move from Cloudflare's dotted strings is a rename. A string is names separated by dots; an array is the names, which are not split. FORGE-3 expects to rename the key to `x-forge-namespace`. | ||
| - A native key at the wrong level is ignored. Validating later only tightens. | ||
|
|
||
| ## What `forge normalize` does |
There was a problem hiding this comment.
Does forge normalize replace the input file?
There was a problem hiding this comment.
It did when you made this comment, but I think it should not, so I made it require an explicit flag to do in place writes.
Without --write it prints the rewritten document to stdout and changes nothing; --write rewrites each file in place, and only if something in it changes. --check writes nothing and fails if anything would change, and - reads stdin. I've added this to the RFC.
| #[cfg(feature = "normalize")] | ||
| use clap::Parser; | ||
| #[cfg(feature = "normalize")] |
There was a problem hiding this comment.
I don't think we need to feature gate this one
| /// A value an extension gives, and the key it came from, so that an error can | ||
| /// name the key the document wrote. | ||
| #[derive(Default)] | ||
| pub(super) struct Val<T> { | ||
| pub(super) value: T, | ||
| /// The slot of the key that gave the value, which names it. It means | ||
| /// nothing if no key gave one. | ||
| pub(super) slot: u8, | ||
| } | ||
|
|
||
| impl<T> Val<T> { | ||
| /// Keeps `value`, which `hit` gave. No other key says the same thing, and | ||
| /// one written twice is an error before it gets here. | ||
| fn set<F>(&mut self, hit: &Hit<F>, value: T) { | ||
| self.value = value; | ||
| self.slot = hit.slot; | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
From the RFC, we are only reading forge extensions so I don't think we need this anymore.
| } | ||
|
|
||
| /// Rewrites the keys of the document `json` that a native key replaces. | ||
| pub fn normalize<'j>(json: &'j [u8], options: &Options) -> Result<Normalized<'j>, Error> { |
There was a problem hiding this comment.
Because we are using serde it doesn't have to be json at this layer. Serde adds a level of normalization which we can use here. This would allow us to support yaml seamlessly just by adding the serde yaml dep
There was a problem hiding this comment.
It would be great if we could use one parser that handles JSON and YAML, but I don't see viable open-source parsers to depend on. We can land this with JSON only for now, and discuss options for adding YAML support.
…out a file named normalize Review feedback on the README and the RFC. - What no native key says is now explained with the case it is about: a skip on a property is rewritten to `x-forge-skip` with the same value, but `x-forge-skip` is for every target, so a skip for only some targets, written as a list, has no native form and is left as it is written, in both modes. On an operation that key is not read at all. The RFC and the vendor README say it with the keys; the package README, which does not name other tools' keys, says it without them. - The README and `--help` no longer say how to pass a document that is itself called `normalize`.
Review feedback: there is no need to feature gate it. `serde_json` and `memchr` are dependencies, `forge::normalize` and `forge normalize` are always built, and the `normalize` feature, with the `cfg`s of the binary, the library, the native key tables and the test target that went with it, is gone. The `cli` feature still decides whether there is a `forge` command.
Review feedback: `forge` reads native keys only, so there is one key for each thing an extension says, and no need to remember which gave a value. `Val`, its `slot` and its `set` are gone, and the fields of a `Definition` are plain values. An error that names a key names the native key of the field, which `metadata::definition` now exposes, so the messages are the same. The released aggregate still writes the same 3,243 files of `--target ts`, byte for byte.
Review feedback: expanding a glob is a shell concern, and what to skip is the caller's to say. The inputs are files, or `-` for standard input to standard output. Folder expansion, glob expansion and the exclusion of hidden directories and `node_modules` are gone, and so is the `glob` dependency. A pattern the shell did not expand is a path that is not there, and a folder is an error that says so. A file named twice, or by two names of the same file, is still rewritten once.
Review feedback: a command that modifies files should say so. Without `--write`, `forge normalize` prints one document to standard output, rewritten, and leaves the file as it is; with several files it fails and says to use `--write` or `--check`. `--write` rewrites each file in place, only if something in it changes, as it did before. `--check` still writes nothing and fails if anything would change, and `-` still reads standard input and prints. `--write` cannot be given with `--check`, nor with `-`, which is not a file to rewrite. What it says when it prints is that the keys are "rewritten in the output", so that it does not sound like a file was changed.
|
TL;DR: forge normalize rewrites other tools' extensions to x-forge-*, editing only the keys it changes. That works for JSON. For YAML, the only parsers that can do the same are saphyr-parser and granit-parser, and I'm unsure whether we can depend on either. I'd like to know Cloudflare's dependency policy and how many teams have YAML specs. Background: forge normalize rewrites x-fern-, x-stainless- and similar keys to x-forge-*. It edits only what's necessary, so a team migrating its own specs gets a clean diff: no whitespace changes, no lost comments, no number changes. It's built for our own specs first, and other Forge users can use it too. The question is YAML support: Minimal edits need a parser that reports where each key and value sits in the source. I tested the Rust YAML parsers:
Both have small gaps on unusual valid JSON (for example saphyr-parser rejects \ud83d\ude00 escapes). Our own specs don't contain any of them, and we could fix them upstream if someone hits one. Options I see (I'm not sure which fits):
My lean: if the policy allows it, option 2 or 3. |
|
I don't think we need to add JSON and YAML parser just to preserve whitespace. We can simply read the json and yaml to guess the indentation they are at and output with the same indentation. |
|
I ran a round trip with nothing renamed through
Guessing the indentation and writing it back works for files already in Python or serde layout (0 lines changed, measured). It doesn't help with anything above the last row, and Cost: on a 27 MB JSON file, the round trip took about 5× the time and about 7× the file size in memory compared with the byte-level scanner. I haven't measured YAML. A serde mode could refuse to write if the output differs from the input beyond the renamed keys, which turns the first group into errors. It can't bring back comments or style. The current approach edits only the renamed keys' bytes, so none of this applies to JSON. |
|
Reviewers asked whether What #75 buys: about 900 fewer lines, no hand-written scanner, and a walk that is a table of what What it costs, on the 28 MB aggregate:
What it gets wrong, and #73 does not. A file with a key to rewrite is not a minimal edit:
All of it is listed under "Known limitations" in the README of #75, and each item is pinned by a test. What is the same. I ran The question that decides it: how many teams commit the files that YAML: neither PR reads it, and both say so. A parser that reports where each value is, which editing YAML in place needs, has no dependency that I think meets the bar for this project, so that is separate from this choice. |
forgereads the nativex-forge-*keys and nothing else, and a new command,forge normalize, rewrites the extensions other tools write (x-fern-*,x-stainless-skip,x-sensitive) into them. FORGE-1.This is one PR against
main: it carries the native vocabulary of RFC 0001 andforge normalize, so it does not depend on any other PR.What
src/metadata/per kind of object, described inrfcs/0001-metadata-resolution-layer.md:x-forge-method-name,x-forge-group-name(a dotted string or an array),x-forge-skip,x-forge-availability,x-forge-model-name,x-forge-property-name,x-forge-parameter-nameandx-forge-sensitive.forge normalizerewrites the keys of other tools into those, in place:--write: without it one document is printed, rewritten, to standard output. It edits the bytes of the document and does not write it again, so whitespace, key order and the text of every number stay as they were, and a diff is only the keys. A native key is never replaced, and a key is rewritten only at the kind of objectforgereads its native key at.src/normalize/vendor/, per level, and are the only code that names them. A boundary test (tests/boundary.rs) enforces it, andAGENTS.mdsays that compatibility is sectioned off there.Commits:
c012837: all of the above. The engine has no fallback, no tiers and nocompatfeature.fe43db8: until the release that makesforge normalizea step to run first, theforgecommand runs the same rewrite in memory before it parses. Temporary, and only the binary does it. Reverting this commit removes it.d3af272(docs),1c349eb(forge::normalizeis not behind a feature),b96f405(the reader no longer tracks which key gave a value) and5cbfa30(forge normalizetakes files, and the shell expands patterns) and00f943d(forge normalizechanges nothing unless it is given--write).forge normalizereads JSON only for now. YAML is refused with a clear error, and is planned as a follow-up.Breaking
A document written with other tools' keys builds only what its native keys say, unless it has been through
forge normalize. The in-memory rewrite keeps theforgecommand working for now; Rust code that parses aForgeitself does not get it.Performance
forge normalizeon the 28 MB released aggregate, the whole process, best of 20:--leave-old-extensions, first run--checkThe in-memory rewrite plus the parse, same document, best of 30:
ForgeThe whole
forge --target tsrun goes from about 252 ms to 262 ms for a normalized document and to 270 ms for a raw one, which is 7%. The rewrite walks the document once with no tree, and checks it to be JSON on a second thread.Behavior to look at
x-sensitiveis not read by the engine.forge normalizerewrites it tox-forge-sensitive. It is not called deprecated, since other systems write it, and--leave-old-extensionskeeps it for them. @srikrsna: if the engine should keep reading it, that brings back some handling of two keys for one thing.normalizeis lenient, and theforgecommand is not. A key written twice, or a value that cannot be read, is left and warned about byforge normalize. Theforgecommand fails on a key written twice with the error it always gave,duplicate field `x-fern-ignore` at line 1 column 174, and leaves a value that cannot be read for the engine to say.forgefinds later in the document says the column of the rewritten document, which differs from the one read where a key was renamed earlier on the same line. The line is the same. It goes with the in-memory rewrite.Verification
On the released aggregate, against the SDK the engine wrote before the change:
--target tswrites all 3,243 files, byte for byte, from the raw document (through the in-memory rewrite), from the document afterforge normalizein either mode, and throughcat spec.json | forge normalize - | forge - --target ts. Without the rewrite, the raw document writes 7 files, so the check can tell.FORGE_SPEC=openapi.forge.json cargo test --release --test normalize aggregate -- --ignoredbuilds the same model in both modes, with no conflicts.-D warnings) with all features and with none, and their tests (613 and 619). The final tree passes the same and also compiles forx86_64-pc-windows-msvc.Follow-up
Remove the in-memory rewrite in the release that makes
forge normalizerequired: revertfe43db8. Add YAML input toforge normalize.