Skip to content

Register the name for asking which objects a write will rewrite - #6

Merged
samuelduchesne merged 4 commits into
mainfrom
register-changed-objects
Sep 6, 2026
Merged

Register the name for asking which objects a write will rewrite#6
samuelduchesne merged 4 commits into
mainfrom
register-changed-objects

Conversation

@samuelduchesne

Copy link
Copy Markdown
Contributor

Registers IDFDocument.changed_objects / IdfDocument.changedObjects ahead 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 text answers 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_text is None for 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 leaves source_text as 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() and danglingReferences(), 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 what rawText's own doc comment says it is protecting.

validate_governance.py passes, and the ledger entry for lossless-round-trip gains the concept.

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.
@samuelduchesne
samuelduchesne requested review from a team as code owners September 6, 2026 18:48
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.
`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
samuelduchesne merged commit 92044f1 into main Sep 6, 2026
1 check passed
@samuelduchesne
samuelduchesne deleted the register-changed-objects branch September 6, 2026 22:03
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.
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