Register the name for asking which objects a write will rewrite - #6
Merged
Conversation
A preserving write reproduces every object nothing has changed and writes the rest afresh. `the original source text` answers whether it will preserve at all. Nothing answered how much of the file it will change, which is what a save button has to put to a user out loud. It is the one part of the record a consumer cannot derive. A rename clears it on every object that referred to the renamed one, so a consumer counting from its own edit log reports one where the answer is nine, and closing the gap means reimplementing the rule about what a change touches. Registered ahead of the code in both languages, because the naming gate reads the built surface and fires on the change that adds the name. Python could already answer this per object, through `IDFObject.source_text` being None for a changed one. That is the retained TEXT, and its use as a flag is incidental: a consumer reading it has to know that having text means unchanged, which is a sentinel that reads backwards. The entry's notes record that, so the next reader does not think this duplicates something.
A preserving write keeps the author's comments on an object it reformats, including the absence of one on a field written bare. A caller who wants the file annotated instead needs a way to say so, and that way has to add labels without costing a comment line: a comment inside an object is carried by nothing but the field it sits above. An enumeration rather than a boolean, on the same reasoning as ordering: a third behaviour is plausible, reusing the author's comment and appending the unit, and a flag could not say which.
This was referenced Sep 6, 2026
`the objects a preserving write will rewrite` was registered for a consumer that cannot use it. An editor turning an edit into the smallest possible change needs three things: which objects will be rewritten, what text each becomes, and where the old one was. The first two are public in both languages. The third was reachable only through the anchoring, which both keep internal on purpose, so a consumer had to write the whole file and diff it, which is the work the first name exists to avoid. The gap was found by the language server team reading the branch before it merged, which is what asking them to read it before the release was for. Two entries, because the accessor returns something and that something is part of the surface. `SourceSpan` in Python, where `Region` reads as a piece of a surface in a building energy library; `Region` in TypeScript, where it has meant a span of the syntax layer since feature 005 and is already exported. The register carries a name per language precisely so an idiom can differ without the capability diverging. Offsets, not a line and column: a consumer that wants the rendering can compute it from text it already holds, and the reverse costs a scan. Neither entry publishes the anchoring. Both languages answer the question and keep the structure that answers it internal, which is the line `the retained source structure` already draws.
samuelduchesne
added a commit
to idfkit/idfkit-js
that referenced
this pull request
Sep 6, 2026
`changedObjects()` was published for a consumer that could not use it. Turning an edit into the smallest possible change to a file takes three things: WHICH objects will be rewritten, WHAT text each becomes, and WHERE the old one was. The first two were public. The third was reachable only through the anchoring, which is internal on purpose, so a consumer had to write the whole file and diff it, which is the work `changedObjects()` exists to avoid. Registered in idfkit/idfkit-conformance#6, and found by the language server team reading the branch before it merged, which is what asking them to read it was for. Two things it is NOT. It is not `SOURCE`. That symbol is cleared the moment an object is touched, because its absence is what marks the object for rewriting, so it is useless for locating exactly the objects worth locating. `ORIGIN` is the same number recorded once and never cleared, and `regionOf` still checks anchor identity, so an object carrying an index from a file it is no longer in is not handed a range from this one. It is not `statement.region`. The extent a preserving write replaces reaches past the semicolon to a comment on the terminator's own line, which is the last field's comment. A consumer replacing the shorter range would leave that comment behind, describing a field that had just moved, which is the defect `extentEnds` exists to close. Handing out a range the writer does not use would have reopened it outside the writer. Computed once per document, since the retained source does not change after the read. What the method does NOT settle is where the replacement TEXT comes from, and `writeObject` is not the answer: a preserving write hands it the author's own per-field annotations, which are internal, so options built by hand come back with the author's units and notes as generated labels. The doc comment says so rather than leaving a consumer to discover it. That gap is open.
samuelduchesne
added a commit
to idfkit/idfkit
that referenced
this pull request
Sep 6, 2026
`changed_objects()` was published for a consumer that could not use it. Turning an edit into the smallest possible change to a file takes three things: WHICH objects will be rewritten, WHAT text each becomes, and WHERE the old one was. The first two were public. The third was reachable only by walking the syntax tree, so a consumer had to write the whole file and compare, which is the work `changed_objects()` exists to avoid. Registered in idfkit/idfkit-conformance#6, and found by the language server team reading the branch before it merged, which is what asking them to read it was for. The span is where the object WAS and stays answerable after it changes, which is the case it is for: the objects worth locating are the ones being rewritten. Its END excludes whatever separated the object from the next, which a preserving write leaves in place, and INCLUDES a comment on the terminator's own line, which is the last field's comment and is rewritten with the object. A range stopping at the semicolon would leave that comment behind, describing a field that had just moved. `SourceSpan` rather than `Region`, which in a building energy library reads as a piece of a surface. The TypeScript core keeps `Region`, where it has meant a span of the syntax layer since that reader was written and is already exported. Frozen, so the two offsets cannot be transposed by a reader and so a later addition beside them is not a breaking change. Built once per document into a map rather than walked per call, since a consumer asks this for every changed object and the tree does not change after the read. What the method does NOT settle is where the replacement TEXT comes from: a preserving write hands the formatter the object's own source, so text produced any other way comes back with the author's units and notes as generated labels. The docstring says so rather than leaving a consumer to discover it. That gap is open.
`where an object's characters sit in the retained source` was registered so that
`the objects a preserving write will rewrite` could be used. Building it showed
the pair still cannot: a consumer knows which objects change and where the old
text is, and has no correct way to produce the new text for one object.
The ordinary per-object writer is not it. A preserving write hands that writer the
author's own per-field annotations, which are internal in both languages, so a
consumer calling it with arguments built by hand gets the author's units and notes
back as generated labels. Measured, on one field of one object: `!- North Axis
{deg}` came back `!- North Axis`.
The accessor shipped with that stated on it, and a doc comment is not a
load-bearing place for a correctness constraint. Two readers reached the same
conclusion independently, from the language service and from the web editor, that
the range alone changes nothing they would build.
samuelduchesne
added a commit
to idfkit/idfkit-js
that referenced
this pull request
Sep 6, 2026
`regionOf` told a consumer where to put text it could not correctly generate. The
one public function shaped to fill the hole is `writeObject`, and a preserving
write hands that function the author's own per-field annotations, which are
internal, so a caller building options by hand gets the author's units and notes
back as generated labels. `!- North Axis {deg}` came back `!- North Axis`. A unit
lost from an engineering model by an editor asked to save a file.
The doc comment said so, in bold, which is not good enough: we had already agreed
a doc comment is not a load-bearing place for a correctness constraint when the
same argument was made about what `changedObjects()` does not answer. Two readers
reached the same conclusion independently, from the language service and from the
web editor, that the range alone changed nothing they would build.
Registered in idfkit/idfkit-conformance#6.
`renderStatement` is factored out of the preserving walk rather than reimplemented
beside it. Two copies would be two answers to one question, and the question is
which bytes go in the file.
ONE OPTION, `fieldComments`, because it is the only one a preserving write
honours. `indent`, `commentColumn`, `ordering` and `versionFirst` are refused by
`writeIdf` alongside `preserveFormatting`; `comments: false` and `compressed`
defeat preservation entirely and send the document down the formatting path. The
first shape of this method took all of them, which would have let a caller render
one object on terms the surrounding file was not written on, reintroducing the
divergence one layer down.
The test that matters splices every changed object's render into its own range and
asserts the result equals `writeIdf` byte for byte. If that ever fails, an editor
built on these three names is silently writing a different file.
samuelduchesne
added a commit
to idfkit/idfkit
that referenced
this pull request
Sep 6, 2026
`region_of` told a consumer where to put text it could not correctly generate.
The formatter needs the object's own source to keep the author's comments, and a
caller who does not have that text gets generated labels: `!- North Axis {deg}`
came back `!- North Axis`. A unit lost from an engineering model by an editor
asked to save a file.
The docstring said so, which is not good enough: we had already agreed a docstring
is not a load-bearing place for a correctness constraint when the same argument was
made about what `changed_objects()` does not answer. Two readers reached the same
conclusion independently, from the language service and from the web editor, that
the range alone changed nothing they would build.
Registered in idfkit/idfkit-conformance#6.
The method builds the formatter exactly as `_write_idf_lossless` builds it, since
that is the only path that can reach this text, and reads the node's own source
from the tree rather than asking the caller for it.
ONE OPTION, `field_comments`, because it is the only one a preserving write
honours. `indent`, `comment_column`, `ordering` and `version_first` are refused
alongside `preserve_formatting`, and an `output_type` other than "standard" takes
precedence over preservation. Accepting any of them would let a caller render one
object on terms the surrounding file was not written on.
The test that matters splices every changed object's render into its own range and
asserts the result equals `write_idf` exactly. If that ever fails, an editor built
on these three names is silently writing a different file.
samuelduchesne
added a commit
to idfkit/idfkit
that referenced
this pull request
Sep 6, 2026
…199) * Keep the author's field comments on an object the writer reformats An edit asks for a value to be re-rendered. It does not ask for the object's comments to be rebuilt, and rebuilding them destroys everything the schema cannot regenerate. Two things were being lost, on every field of any object touched: - The field's unit. `!- North Axis {deg}` came back as `!- North Axis`, because the generated label is the humanised field name and carries no unit. The schema has it, and the writer was not asking. - Anything the author wrote there. `!- VERIFY WITH CLIENT before the Feb review` was replaced by `!- Terrain` and the note was gone, with nothing in the output to say it ever existed. Neither is a formatting difference. They are text the file contained and the output does not, produced by a writer whose whole purpose on this path is to give the file back. `format_object` now takes the object's original text and reuses its per-field comments, falling back to the generated label for a field the author never wrote one for. A field's comment is the one after its delimiter on the same line, which is the convention every writer of these files follows and the one case where which field a comment belongs to is not a guess; a comment on its own line belongs to no field and is untouched. The syntax tree keeps a node's original text even after its object changes, which is what makes this possible without retaining anything new. Found by a reader of the equivalent TypeScript change, who pointed out that the example there used `{deg}` and so could not tell deletion from regeneration. A genuinely custom comment showed both languages were destroying it. * Keep everything on a reformatted object except its values Three things the writer was destroying when it rewrote an object, none of them a formatting difference and none recoverable afterwards. A COMMENT ON ITS OWN LINE INSIDE THE OBJECT. `! this value came from the 2019 survey`, sitting above the field it describes, was lost. A comment BETWEEN two objects is carried by the text between them and was always safe; one INSIDE an object is carried by nothing, and reformatting dropped it. It is now emitted with the field below it. A FIELD THE AUTHOR LEFT BARE gained a generated label. Absence is as much a thing the author wrote as the words are, so a bare field stays bare. `field_comments` is the escape hatch for a caller who wants the file annotated: it adds labels and never costs a comment line, because the author's own lines are emitted either way. Comments are now attached BY DELIMITER rather than by counting lines. Several fields share a line in real files, a surface's vertices routinely three to a line with one comment for the triple, and the line-counting version this replaces mis-attached every comment after the first such line. `changed_objects()` answers which objects a preserving write will rewrite. A consumer cannot derive it: a rename clears the record on every object that referred to the renamed one, so counting from an edit log reports one where the answer is nine. `IDFObject.source_text` is the same record one object at a time, and it is the retained TEXT rather than a flag, so a reader has to know that HAVING text means unchanged. This asks directly. The naming gate fails until governance-2026.15 is published and pinned, which is the register-lands-first rule working rather than a fault in this branch. * Say what changed_objects() does not answer It yields objects a write will REWRITE, and a removal separates that from everything that will differ: the removed object is no longer in the document to be yielded, so this returns nothing for a write that changes the file. Comparing the write with raw_text is the question that answers. * Keep values on the line the author put them on A reformatted object was written one value per line whatever the source said. Measured across the 693 EnergyPlus 22.1.0 example files, that is not a corner case: 21.5 percent of statements group several values on a line, 690 of the 693 files contain at least one, and a full reformat of the corpus would add 20.2 percent to its line count with 89.6 percent of that from this one shape. A four-line surface became twelve lines. The annotation each field already carries gains `starts_line`, which the scan knew and was throwing away, and the emitter builds a line rather than pushing one per field. Editing one wall of 1ZoneUncontrolled leaves the file at 462 lines rather than 470. The type name is part of the line under construction rather than pushed ahead of it, so an object written `Timestep,4;` comes back on one line. That is another 11.3 percent of statements and the case that surprises on a file with no geometry in it: nobody thinks of `Timestep,4;` as formatting they chose. Two defects fell out of building it, both older than this change: An object at the END of a file gained a blank line every time it was reformatted. A node's text runs to whatever separated it from the next object, one newline for the last one, and the writer appended a fixed two. `nocomment` output no longer loaded. The type name is the line still open when the field loop starts, so the branch that skipped the flush emitted it last. The corpus caught that one, and there is now a test that reparses the output. * Make the changed_objects() example true The example claimed two objects where a new document holds four before the Zone is added. It was written and never run, which is how it landed wrong: the doctest arrived with the docstring and the suite was last run before it. The number was the wrong thing to assert anyway. What the example is for is that nothing was read, so state that: the changed set equals every object. It is now also a claim that stays true if the default document gains an object. * Put the comment where EnergyPlus puts it, and keep the blanks the author wrote Two defects a peer found by forcing 263,471 objects from the 693 EnergyPlus 22.1.0 example files through the writer and attributing every difference. Both predate the preserving writer; both are the kind that only shows once a write is expected to give the file back. THE COLUMN. `comment_column` is documented as a column and was applied as an index, so `!-` landed one place right of where the files it imitates put it. Across 1,504,802 comment lines whose content came back byte-identical, 91 percent moved by exactly one. `1ZoneUncontrolled.idf` writes the marker at index 29 on 223 of its 231 commented lines; this writer wrote 30. On a preserving write that is the difference that shows. A rewritten object's comments stood one column clear of every untouched object around it, so every save left a visible seam at the edit. The Version line is left where it was. It is written by hand rather than through the field loop and its literal run of spaces is pinned by a test that says why. THE BLANKS. The writer stops at the last field that is SET, so a run of commas the author wrote out is dropped and the field-name comments go with them. One `Sizing:System` went from 38 lines to 22 on a single-field edit; corpus-wide it is 20,571 lines, more than any other difference a rewrite makes, led by ComponentCost:LineItem at 4,548 and Coil:Heating:Water at 2,475. A field written out as a blank is as much a thing the author wrote as a field left bare of its comment, and that second rule is one this path already follows. Reaching the opposite answer on the first was an inconsistency, not a decision. Only on the preserving path: `keep_at_least` is the author's own field count and is zero everywhere else, so a write with nothing to reproduce trims as it always has. Restructuring `flush` for the column also cost a bug on the way in, caught by the test written for the last one: an early return skipped clearing the open line, so the type name came back once per field. * Say where an object's characters were `changed_objects()` was published for a consumer that could not use it. Turning an edit into the smallest possible change to a file takes three things: WHICH objects will be rewritten, WHAT text each becomes, and WHERE the old one was. The first two were public. The third was reachable only by walking the syntax tree, so a consumer had to write the whole file and compare, which is the work `changed_objects()` exists to avoid. Registered in idfkit/idfkit-conformance#6, and found by the language server team reading the branch before it merged, which is what asking them to read it was for. The span is where the object WAS and stays answerable after it changes, which is the case it is for: the objects worth locating are the ones being rewritten. Its END excludes whatever separated the object from the next, which a preserving write leaves in place, and INCLUDES a comment on the terminator's own line, which is the last field's comment and is rewritten with the object. A range stopping at the semicolon would leave that comment behind, describing a field that had just moved. `SourceSpan` rather than `Region`, which in a building energy library reads as a piece of a surface. The TypeScript core keeps `Region`, where it has meant a span of the syntax layer since that reader was written and is already exported. Frozen, so the two offsets cannot be transposed by a reader and so a later addition beside them is not a breaking change. Built once per document into a map rather than walked per call, since a consumer asks this for every changed object and the tree does not change after the read. What the method does NOT settle is where the replacement TEXT comes from: a preserving write hands the formatter the object's own source, so text produced any other way comes back with the author's units and notes as generated labels. The docstring says so rather than leaving a consumer to discover it. That gap is open. * Give back the text that belongs in that range `region_of` told a consumer where to put text it could not correctly generate. The formatter needs the object's own source to keep the author's comments, and a caller who does not have that text gets generated labels: `!- North Axis {deg}` came back `!- North Axis`. A unit lost from an engineering model by an editor asked to save a file. The docstring said so, which is not good enough: we had already agreed a docstring is not a load-bearing place for a correctness constraint when the same argument was made about what `changed_objects()` does not answer. Two readers reached the same conclusion independently, from the language service and from the web editor, that the range alone changed nothing they would build. Registered in idfkit/idfkit-conformance#6. The method builds the formatter exactly as `_write_idf_lossless` builds it, since that is the only path that can reach this text, and reads the node's own source from the tree rather than asking the caller for it. ONE OPTION, `field_comments`, because it is the only one a preserving write honours. `indent`, `comment_column`, `ordering` and `version_first` are refused alongside `preserve_formatting`, and an `output_type` other than "standard" takes precedence over preservation. Accepting any of them would let a caller render one object on terms the surrounding file was not written on. The test that matters splices every changed object's render into its own range and asserts the result equals `write_idf` exactly. If that ever fails, an editor built on these three names is silently writing a different file. * Walk the tree once, and say where an object ends in one place A cleanup pass over the preserving writer. No output moves: the corpus passes with the same counts and all 3,449 tests hold. ONE WALK. `region_of` built a cached index of every anchored object; `render_object` beside it rescanned every node to find the one it wanted. The loop both docstrings recommend, over each changed object, was quadratic in the size of the file. They now share one index carrying the span and the node, built in the pass `region_of` already made. ONE BOUNDARY. Where an object's text ends and the gap after it begins was stated three times: twice as `len(node.text.rstrip("\n"))` in two modules, once as its complement for the separator. It is `CSTNode.body_length` now, with `separator` beside it. That number is what makes a rendered object splice back into the range it came from, and the test asserting that only exercises the happy path, so two copies of it were two chances to be wrong about the same thing. `rstrip` also allocated a copy of every object in the file to look at its last few characters. ONE FORMATTER. `render_object` built its own `IDFWriter` where the lossless walk builds one; `render_cst_node` is now the single construction both use. `field_comments` had to be threaded through this path by hand once already, which is what the next option would have hit. THE VERSION LINE IS NOT A COLUMN, and the comment claiming it was is what made it look aligned. It is a fixed twenty-space gap, so the marker lands at 26 after `9.0`, 27 after `26.1`, 28 after `9.0.1`, drifting with the identifier while every other line holds `comment_column`. That was invisible while the padding was itself off by one and everything was uniformly wrong; correcting the padding left this as the one line out of step. Kept as it is, deliberately, because regularising it moves the first line of every file this writer has produced and a preserving write never reaches that path. The comment and the test now say what is true. The nocomment branch is hoisted out of the field loop rather than tested per field. It also retires the subtlety that carried it: the type name is the line still open when the loop starts, so that path depended on the first flush emitting it, which cost a regression once already. Both languages express the author's field count in different index spaces, values including the name here and an index excluding it there. Each is right against its own annotations and neither would notice the other moving, so each now points at the other. Two test classes read one fixture rather than two identical copies. * Emit the stub's imports in the order the formatter wants them The stub gate regenerates `document.pyi` and diffs it against what is committed. The generator emitted the new import next to the one it relates to; the formatter that runs afterwards sorted it to the end. So the committed file was sorted, the regenerated one was not, and the gate failed everywhere except the machine that committed it, where the formatter had already run. Alphabetical in the generator, with a comment saying why, since the next import added here would land the same way. * Say where the preserving write stops being the cheap one A peer measuring across four models found that "a preserving write is 7x to 45x faster than a formatting one" is an UNEDITED-document number and does not hold in general. It inverts once most objects have changed: this path renders each changed object and walks the tiling, which is strictly more work than formatting alone. On a 13 MB model with every object edited it is 409 ms against 104 ms. Not a defect and not worth a guard. What a caller actually pays is nought to a hundred objects edited, which is 0.1 ms to 4.4 ms, and a whole model rewrite is what preserve_formatting=False is for. But an unstated crossover is how somebody benchmarks the wrong path and reports the wrong number, which is exactly what happened, so the docstring says it. Both libraries, same words. * Adopt conformance-2026.11 and governance-2026.15 The runner level first. The case set is unchanged from 2026.10, 69 cases and 211 assertions, and this library sees no new expectation. What it sees is a runner that stops reporting a false failure on preserve-edit-one-field: the span reconstruction joined objects with a fixed two newlines, so the last statement in a file ran a line past where the writer put it. The governance level carries the five names this feature adds, none of which renames anything: changed_objects, region_of, SourceSpan, render_object and field_comments. The naming gate now passes with 135 public names resolving, where before it blocked on four this library had defined ahead of the register. That order is the rule rather than an accident. The entries were published in idfkit-conformance and the tag cut from its main before this pin moved, so a gate here could never have gone green against something a reviewer had not seen.
samuelduchesne
added a commit
to idfkit/idfkit-js
that referenced
this pull request
Sep 6, 2026
) * Absorb a terminator-line comment into the statement it ends Closes #47. A statement's region ends at its terminator, so a comment after the semicolon on the same line sat in the gap. That is invisible while the object is copied, because the gap is copied too, and wrong the moment it is reformatted: the writer emits its own field comment and the author's then arrives from the gap on the line below. It is not even a duplicate, because the ordinary writer drops the unit, so `!- North Axis {deg}` reads as a stray fragment under `!- North Axis`. The comment written on this file's walk defended the old behaviour on the ground that the alternative is a writer guessing which comments belong to which object. That defence is right about the general case and does not apply to this one: "on the same line as the terminator" is a positional fact, and it is the one case where the owner is not in question. The writer had just emitted its own version of the very comment it then copied. It reads as rarer than it is. IDFEditor writes a field comment on every line of every object including the terminator line, so about half the statements in a typical file gained a stray line the moment they were edited. THE FIRST LANGUAGE ALREADY DID THIS. Its concrete syntax tree runs a node to the end of the line, so it absorbed the comment and replaced it, and it drops the comment on removal too. This is TypeScript catching up rather than a new rule, which is why no divergence entry is added and none goes stale. Three consequences, decided rather than discovered: - A file written unchanged is byte-identical still: the extent grows and the gap shrinks by exactly the same characters. - Removing an object takes that comment with it, where it used to be left on a line of its own describing a field that no longer exists. The test that pinned the old behaviour now pins this one. - A comment on the next line, or after a blank one, stays in the gap. That is where deciding which object a comment belongs to actually becomes a guess. * Keep the author's field comments on an object the writer reformats Supersedes the first attempt at #47, which deleted the comment instead. That attempt absorbed a terminator-line comment into the statement so it could not arrive twice, and reached agreement with Python by matching it on the more destructive of two behaviours. A reader pointed out that the example used `!- North Axis {deg}`, which the writer can regenerate, so deletion and regeneration looked identical. A genuinely custom comment told them apart: before, as shipped in 0.3.0-rc.1: survives, on a line of its own below after that attempt: deleted now: kept, in place, once An edit asks for a value to be re-rendered and not for the object's comments to be rebuilt. Rebuilding them destroyed the field's unit, which the generated label does not carry, and any note the author wrote there. `writeObject` takes the author's comment per field where there is one, and the layer supplies them: a field's comment is the one after its delimiter on the same line, which is the same positional rule the terminator case established, generalised. A comment on its own line belongs to no field and stays in the gap. This subsumes the duplicate. The comment is emitted once, by the writer, in the place it was written, so there is nothing left in the gap to arrive below it. * Emit no line break the statement's extent did not cover A reformatted object grew the file by a blank line, and would grow it again on every save. A statement's extent ends at its terminator, or at the comment on that same line, and in neither case includes the line break: the break is the first character of the gap. `writeObject` ends with one because it also writes whole documents, so the break went in twice. This was there before the comment work and was invisible. The misplaced terminator comment sat between the two breaks, so it read as one blank line; removing the comment left the second break with nothing in front of it. Found by a consumer measuring a one-field edit: 476 lines out against 475 in, on a file where the requirement being written against is that a one-object edit changes only the lines of that object. A blank line after the object is a line outside it. * Keep everything on a reformatted object except its values Mirrors idfkit's change of the same name; the two behave identically on every case measured. A comment on its own line INSIDE an object was lost when the object was reformatted. One between two objects is carried by the gap and was always safe; one inside is carried by nothing. It is now emitted with the field below it. A field the author left bare gained a generated label. Absence is as much a thing the author wrote as the words are, so bare stays bare, and `fieldComments: 'generate'` is the escape hatch for a caller who wants the file annotated: it adds labels and never costs a comment line. `changedObjects()` answers which objects a preserving write will rewrite, which a consumer cannot derive: a rename clears the record on every object that referred to the renamed one, so counting from an edit log reports one where the answer is nine. CI is red until governance-2026.15 is published and pinned. The naming gate reads the built surface and fires on the change that adds a name, which is the register-lands-first rule doing its job. * Say what changedObjects() does not answer It yields objects a write will REWRITE, and a consumer reading the old comment could take it for everything that will differ. A removal separates the two: the removed object is no longer in the document to be yielded, so this returns nothing for a write that changes the file, and treating an empty result as 'the file is unchanged' is wrong on every removal. Comparing the write with rawText is the question that answers; this one is how much is being written afresh. Found by a consumer whose save outcome already did it correctly and who pointed out the next one would not. * Keep values on the line the author put them on A reformatted object was written one value per line whatever the source said. Measured across the 693 EnergyPlus 22.1.0 example files, that is not a corner case: 21.5 percent of statements group several values on a line, 690 of the 693 files contain at least one, and a full reformat of the corpus would add 20.2 percent to its line count with 89.6 percent of that from this one shape. A four-line surface became twelve lines. `FieldAnnotation` gains `startsLine`, which the delimiter scan already knew and was discarding, and the emitter builds a line rather than pushing one per field. Editing one wall of 1ZoneUncontrolled leaves the file at 462 lines rather than 470. The type name is the line under construction rather than something pushed ahead of it, so an object written `Timestep,4;` comes back on one line. That is another 11.3 percent of statements and the case that surprises on a file with no geometry in it: nobody thinks of `Timestep,4;` as formatting they chose. That restructuring broke `comments: false` output, which emitted the type name last and no longer parsed. The corpus caught it; the test that granted the output form now reparses what it got. * Put the comment where EnergyPlus puts it, and keep the blanks the author wrote Two defects a peer found by forcing 263,471 objects from the 693 EnergyPlus 22.1.0 example files through the writer and attributing every difference. Both predate the preserving writer; both are the kind that only shows once a write is expected to give the file back. THE COLUMN. `commentColumn` is documented as a column and was applied as an index, so `!-` landed one place right of where the files it imitates put it. Across 1,504,802 comment lines whose content came back byte-identical, 91 percent moved by exactly one. `1ZoneUncontrolled.idf` writes the marker at index 29 on 223 of its 231 commented lines; this writer wrote 30. On a preserving write that is the difference that shows. A rewritten object's comments stood one column clear of every untouched object around it, so every save left a visible seam at the edit. THE BLANKS. The writer stops at the last field that is SET, so a run of commas the author wrote out is dropped and the field-name comments go with them. One `Sizing:System` went from 38 lines to 22 on a single-field edit; corpus-wide it is 20,571 lines, more than any other difference a rewrite makes, led by ComponentCost:LineItem at 4,548 and Coil:Heating:Water at 2,475. A field written out as a blank is as much a thing the author wrote as a field left bare of its comment, and that second rule is one this path already follows. Reaching the opposite answer on the first was an inconsistency, not a decision. Only on the preserving path: the annotations are the author's own field count and are absent everywhere else, so a write with nothing to reproduce trims as it always has. The extensible rule still wins over both, since a missing fixed slot lands every group in the wrong position. The tutorial's rendered output moves one column with everything else. * Say where an object's characters were `changedObjects()` was published for a consumer that could not use it. Turning an edit into the smallest possible change to a file takes three things: WHICH objects will be rewritten, WHAT text each becomes, and WHERE the old one was. The first two were public. The third was reachable only through the anchoring, which is internal on purpose, so a consumer had to write the whole file and diff it, which is the work `changedObjects()` exists to avoid. Registered in idfkit/idfkit-conformance#6, and found by the language server team reading the branch before it merged, which is what asking them to read it was for. Two things it is NOT. It is not `SOURCE`. That symbol is cleared the moment an object is touched, because its absence is what marks the object for rewriting, so it is useless for locating exactly the objects worth locating. `ORIGIN` is the same number recorded once and never cleared, and `regionOf` still checks anchor identity, so an object carrying an index from a file it is no longer in is not handed a range from this one. It is not `statement.region`. The extent a preserving write replaces reaches past the semicolon to a comment on the terminator's own line, which is the last field's comment. A consumer replacing the shorter range would leave that comment behind, describing a field that had just moved, which is the defect `extentEnds` exists to close. Handing out a range the writer does not use would have reopened it outside the writer. Computed once per document, since the retained source does not change after the read. What the method does NOT settle is where the replacement TEXT comes from, and `writeObject` is not the answer: a preserving write hands it the author's own per-field annotations, which are internal, so options built by hand come back with the author's units and notes as generated labels. The doc comment says so rather than leaving a consumer to discover it. That gap is open. * Give back the text that belongs in that range `regionOf` told a consumer where to put text it could not correctly generate. The one public function shaped to fill the hole is `writeObject`, and a preserving write hands that function the author's own per-field annotations, which are internal, so a caller building options by hand gets the author's units and notes back as generated labels. `!- North Axis {deg}` came back `!- North Axis`. A unit lost from an engineering model by an editor asked to save a file. The doc comment said so, in bold, which is not good enough: we had already agreed a doc comment is not a load-bearing place for a correctness constraint when the same argument was made about what `changedObjects()` does not answer. Two readers reached the same conclusion independently, from the language service and from the web editor, that the range alone changed nothing they would build. Registered in idfkit/idfkit-conformance#6. `renderStatement` is factored out of the preserving walk rather than reimplemented beside it. Two copies would be two answers to one question, and the question is which bytes go in the file. ONE OPTION, `fieldComments`, because it is the only one a preserving write honours. `indent`, `commentColumn`, `ordering` and `versionFirst` are refused by `writeIdf` alongside `preserveFormatting`; `comments: false` and `compressed` defeat preservation entirely and send the document down the formatting path. The first shape of this method took all of them, which would have let a caller render one object on terms the surrounding file was not written on, reintroducing the divergence one layer down. The test that matters splices every changed object's render into its own range and asserts the result equals `writeIdf` byte for byte. If that ever fails, an editor built on these three names is silently writing a different file. * Derive each fact about a statement once A cleanup pass over the preserving writer. No output moves: both corpora pass with the same counts and all 779 tests hold. THE ONE THAT MATTERED. `annotations` started its token cursor at zero and seeked forward to the statement it wanted, which is a full prefix scan of the token stream per statement and quadratic in the file. Reformatting every object of HospitalLowEnergy.idf, 6,874 statements over 233,925 tokens, took 647 ms. It now takes 76 ms. Neither benchmark covers it. Both time an UNCHANGED preserving write, where every statement is copied verbatim and `annotations` is never reached, so the budget gate would have held at any cost on this path. I nearly missed it twice: my first A/B showed no difference because the harness set each field to the value it already held, which the writer correctly treats as no edit at all, so I was timing the same untouched path the benchmarks time. The fix is real; the first measurement of it was not. `extentEnds` and the cursor now come from one memo on the retained source, which never changes after the read. Before this, the extent was derived in two places with two lifetimes: `writePreserved` recomputed it on every write, and `regionOf` cached it in a field of its own. One immutable derivation with two caches is a disagreement waiting to happen, and the doc comment saying `extentEnds` was exported "so regionOf answers with the same extent" was the tell. THE REST, none of which changes behaviour: The anchor rule lives once, as `originOf`, beside the `isUntouched` it mirrors. `regionOf` and `renderObject` are documented as declining the same set and were deciding it separately. `preservingOptions` resolves what a preserving write resolves. `renderObject` had its own copy of the defaults, including the comment column whose value carries a six-line argument from the example corpus; moving that argument would have left the accessor on the old number, silently breaking the byte-for-byte agreement its own doc comment promises. A private field sat between `regionOf`'s doc comment and `regionOf`, so the whole comment, worked example included, documented the field and the method shipped bare. Removing the field fixed it. `annotations` no longer pre-fills entries the loop overwrites unconditionally: a placeholder is a second, contradictory statement of what an entry defaults to. The `ORIGIN` comment no longer names the strip tag in prose. `stripInternal` matches the tag as TEXT anywhere in a comment, so explaining the hazard triggered it, dropped the symbol from the emitted types and broke the build. The comment now says so without saying it. Two tests read one fixture rather than two identical copies, so a change to it cannot leave them asserting against different files while both pass. * Run the formatter over what the cleanup pass touched Prettier's own check, which `npm run lint` does not include, so the two files the last commit reflowed only failed once CI reached them. * Say where the preserving write stops being the cheap one A peer measuring across four models found that "a preserving write is 7x to 45x faster than a formatting one" is an UNEDITED-document number and does not hold in general. It inverts once most objects have changed: this path renders each changed object and walks the tiling, which is strictly more work than formatting alone. On a 13 MB model with every object edited it is 409 ms against 104 ms. Not a defect and not worth a guard. What a caller actually pays is nought to a hundred objects edited, which is 0.1 ms to 4.4 ms, and a whole model rewrite is what `preserveFormatting: false` is for. But an unstated crossover is how somebody benchmarks the wrong path and reports the wrong number, which is exactly what happened, so the doc comment says it. Both libraries, same words. * Adopt conformance-2026.11 and governance-2026.15 The runner level first. The case set is unchanged from 2026.10, 69 cases and 211 assertions, and this library sees no new expectation. What it sees is a runner that stops reporting a false failure on preserve-edit-one-field, and the terminator-comment rule mirrored into the JavaScript runner so the two agree about where a statement's text ends. The governance level carries the five names this feature adds, none of which renames anything: changedObjects, regionOf, Region, renderObject and fieldComments. The naming gate passes with 136 of 136 public names resolving. That order is the rule rather than an accident. The entries were published in idfkit-conformance and the tag cut from its main before this pin moved, so a gate here could never have gone green against something a reviewer had not seen. * Attest conformance-2026.11 as the level both libraries publish at The constant FR-044 rests on, moved only after checking the other library rather than after moving this one's pin. Evidence taken today against the corpus checked out AT THE TAG, not read off a badge: idfkit-js packages/core/package.json idfkit.conformance = conformance-2026.11 idfkit pyproject.toml [tool.idfkit.conformance] = conformance-2026.11 idfkit-js npm run check:release PASS at that level idfkit uv run python scripts/check_release_conformance.py PASS at that level 2026.11 changes no case. 69 cases and 211 assertions, as 2026.10 had; what moved is the runners, which stopped reporting a false failure on preserve-edit-one-field. So the precondition is met on the same evidence it was, rather than on a weaker one. The gate caught this: bumping the pin without moving the constant failed `emit-conformance.mjs --check`, which is the cheap failure it was added to produce after the pair went stale once between 2026.7 and 2026.8 and surfaced only when a release was attempted.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Registers
IDFDocument.changed_objects/IdfDocument.changedObjectsahead of the code, for idfkit/idfkit-js#50.The naming gate reads each package's built surface and fires on the change that adds a name, so the register has to be published at a tag first. This is that change; the implementations in both libraries follow, pinning the level cut from this.
The gap it closes
the original source textanswers whether a write will preserve. Nothing answered how much of the file it will change — the question a save button has to put to a user out loud.It is the one part of the touched record a consumer cannot derive. A rename clears it on every object that referred to the renamed one, so a consumer counting from its own edit log reports one where the answer is nine. Getting there means reimplementing the rule about what a change touches, which is a second opinion about the same question and drifts the day the rule moves.
Why this is not a duplicate of something Python has
Python could already answer it per object:
IDFObject.source_textisNonefor a changed object, and the writer branches on exactly that. But that is the retained text, and its use as a flag is incidental to what it is — a consumer reading it has to know that having text means unchanged, a sentinel that reads backwards. The entry asks the question directly, in both languages, and leavessource_textas the text it is.The entry's notes say all of this, so the next reader does not have to rediscover it.
Shape
A generator beside
objects()anddanglingReferences(), rather than a count, so a consumer that wants to list what it is about to reformat can; a count is the length of what it yields. The anchoring behind the record stays internal in both languages, which is whatrawText's own doc comment says it is protecting.validate_governance.pypasses, and the ledger entry forlossless-round-tripgains the concept.