From e6d543face72845dc3ed1fa4b9b257de431a89a9 Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Sun, 6 Sep 2026 09:30:49 -0400 Subject: [PATCH 1/3] A ninth assertion, which compares bytes, and the thirteen cases that carry it `preserved-text` asserts that a library's preserving write of a text equals that text, byte for byte. It is the corpus's first byte-level assertion and its last: rule 1 of runners/compare.md forbids textual comparison, and the exception added here is bounded by a DIRECTION rather than by an assertion number, so a second one cannot be added by precedent. This assertion compares a library's output to that same library's own input and never to the other library's output, which is the direction the rule's reason has no purchase on: it is about two libraries rendering one value as two texts, and this assertion's whole content is that nothing was re-rendered. It is not `round-trip` under another name. Round-trip re-reads a library's own output and compares documents, saying nothing about the bytes in between, and it writes with preservation explicitly OFF for that reason. This is that echo, asserted deliberately. Nine cases carry byte hazards the curated set does not hold, because that set was swept from what one engine emits: two line-ending conventions and both in one file, a file with no trailing newline, a comment carrying both delimiters, a value across two lines, an unterminated final statement, a duplicate name, a file of comments and an empty one. All nine are donated from the second language's syntax fixtures. Four more declare `operations`, a typed list of the four changes the touched record has to get right, and narrow the assertion to the property FR-023 states: every byte outside the extents of the objects those operations touched equals the input. `preserve-edit-no-op` is the case that caught the first language writing a field the value it already held and reformatting the object for it. The corpus goes from 56 cases and 148 assertions to 69 and 211. The 37 oracle cases gain the assertion over their input and over their committed `expected.epJSON` read as an object-notation input: those files carry a third tool's formatting rather than either library's, so the object-notation half needs no new fixture. The second language has no preserving write yet, which is why 46 entries land in known-divergence.toml naming idfkit-js#12. The assertion is cut while it fails, which is what makes the corpus the specification of the behaviour rather than a check applied afterwards, and the change that lands the writer removes them. Two first-language entries are added and neither is a finding this assertion exposed: idfkit#185 and idfkit#187 were open already and already carried entries on other assertions of the same cases. The four findings this assertion did expose were fixed in idfkit rather than accepted here. --- README.md | 53 +- cases/encoding-latin1-high-bytes/case.toml | 2 +- cases/encoding-utf8-object-name/case.toml | 2 +- cases/extensible-empty-group/case.toml | 2 +- cases/extensible-partial-group/case.toml | 2 +- .../case.toml | 2 +- cases/extensible-single-vs-multiple/case.toml | 2 +- cases/extensible-wrapper-key/case.toml | 2 +- cases/naming-blank-vs-absent/case.toml | 2 +- cases/naming-synthetic-collision/case.toml | 2 +- cases/naming-synthetic-key/case.toml | 2 +- cases/numeric-autocalculate-casing/case.toml | 2 +- cases/numeric-autosize-casing/case.toml | 2 +- cases/numeric-integer-field/case.toml | 2 +- cases/numeric-scientific-notation/case.toml | 2 +- cases/numeric-sentinel-schema-remap/case.toml | 2 +- cases/numeric-zero-against-blank/case.toml | 2 +- .../case.toml | 2 +- .../case.toml | 2 +- .../case.toml | 2 +- cases/preserve-comment-hazards/case.toml | 16 + cases/preserve-comment-hazards/input.idf | 6 + cases/preserve-comments-only/case.toml | 20 + cases/preserve-comments-only/input.idf | 6 + cases/preserve-duplicate-name/case.toml | 19 + cases/preserve-duplicate-name/input.idf | 9 + cases/preserve-edit-no-op/case.toml | 19 + cases/preserve-edit-no-op/input.idf | 24 + cases/preserve-edit-one-field/case.toml | 18 + cases/preserve-edit-one-field/input.idf | 24 + cases/preserve-edit-remove-and-add/case.toml | 21 + cases/preserve-edit-remove-and-add/input.idf | 24 + cases/preserve-edit-rename/case.toml | 21 + cases/preserve-edit-rename/input.idf | 24 + cases/preserve-empty/case.toml | 17 + cases/preserve-empty/input.idf | 0 cases/preserve-line-endings-crlf/case.toml | 19 + cases/preserve-line-endings-crlf/input.idf | 8 + cases/preserve-line-endings-mixed/case.toml | 17 + cases/preserve-line-endings-mixed/input.idf | 8 + cases/preserve-no-trailing-newline/case.toml | 18 + cases/preserve-no-trailing-newline/input.idf | 6 + cases/preserve-unterminated-final/case.toml | 24 + cases/preserve-unterminated-final/input.idf | 6 + cases/preserve-value-across-lines/case.toml | 19 + cases/preserve-value-across-lines/input.idf | 6 + .../case.toml | 2 +- cases/references-dangling-name/case.toml | 2 +- cases/references-self-referential/case.toml | 2 +- cases/types-choice-field-casing/case.toml | 2 +- cases/types-object-type-name-casing/case.toml | 2 +- cases/versions-22-1-0/case.toml | 2 +- cases/versions-22-2-0/case.toml | 2 +- cases/versions-23-1-0/case.toml | 2 +- cases/versions-23-2-0/case.toml | 2 +- cases/versions-24-1-0/case.toml | 2 +- cases/versions-24-2-0/case.toml | 2 +- cases/versions-25-1-0/case.toml | 2 +- cases/versions-25-2-0/case.toml | 2 +- cases/versions-26-1-0/case.toml | 2 +- cases/versions-9-5-0/case.toml | 2 +- cases/versions-9-6-0/case.toml | 2 +- cases/versions-canonical-string/case.toml | 2 +- cases/versions-late-version-object/case.toml | 2 +- known-divergence.toml | 486 +++++++++++++++++- manifest.json | 338 ++++++++++-- manifest.schema.json | 68 ++- runners/compare.md | 53 ++ runners/compare.mjs | 363 +++++++++++++ runners/compare.py | 266 +++++++++- runners/model.mjs | 182 +++++++ runners/model.py | 148 ++++++ runners/run.mjs | 320 +++++++++++- runners/run.py | 267 ++++++++++ runners/tests/compare_fixtures.json | 78 ++- runners/tests/test-compare.mjs | 115 ++++- runners/tests/test_compare.py | 104 +++- 77 files changed, 3182 insertions(+), 132 deletions(-) create mode 100644 cases/preserve-comment-hazards/case.toml create mode 100644 cases/preserve-comment-hazards/input.idf create mode 100644 cases/preserve-comments-only/case.toml create mode 100644 cases/preserve-comments-only/input.idf create mode 100644 cases/preserve-duplicate-name/case.toml create mode 100644 cases/preserve-duplicate-name/input.idf create mode 100644 cases/preserve-edit-no-op/case.toml create mode 100644 cases/preserve-edit-no-op/input.idf create mode 100644 cases/preserve-edit-one-field/case.toml create mode 100644 cases/preserve-edit-one-field/input.idf create mode 100644 cases/preserve-edit-remove-and-add/case.toml create mode 100644 cases/preserve-edit-remove-and-add/input.idf create mode 100644 cases/preserve-edit-rename/case.toml create mode 100644 cases/preserve-edit-rename/input.idf create mode 100644 cases/preserve-empty/case.toml create mode 100644 cases/preserve-empty/input.idf create mode 100644 cases/preserve-line-endings-crlf/case.toml create mode 100644 cases/preserve-line-endings-crlf/input.idf create mode 100644 cases/preserve-line-endings-mixed/case.toml create mode 100644 cases/preserve-line-endings-mixed/input.idf create mode 100644 cases/preserve-no-trailing-newline/case.toml create mode 100644 cases/preserve-no-trailing-newline/input.idf create mode 100644 cases/preserve-unterminated-final/case.toml create mode 100644 cases/preserve-unterminated-final/input.idf create mode 100644 cases/preserve-value-across-lines/case.toml create mode 100644 cases/preserve-value-across-lines/input.idf diff --git a/README.md b/README.md index efa0853..948af37 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ happened on the first fixture written. ## Read this first: the coverage this corpus does NOT have -Partial coverage is easy to mistake for complete coverage. Five gaps are open. +Partial coverage is easy to mistake for complete coverage. Six gaps are open. ### EnergyPlus 26.1.0 only @@ -75,7 +75,19 @@ for. It has no members yet. A second consequence, worth stating separately: cases are curated from a sweep of the EnergyPlus example files, so the corpus only sees hazards that EnergyPlus's own files exhibit. Byte-order marks, -CRLF line endings, and the other things real editors emit are not in that set. +CRLF line endings, and the other things real editors emit are not in that set. Nine `preserve-*` +cases were donated in `conformance-2026.9` to close part of that, and they close it for the parse +and the write; they do not close it for the read from disk, which is this section's subject. + +**Assertion 9 lives on this side of the boundary too.** `preserved-text` proves that a library +reproduces the text its read was GIVEN, not that it reproduces a file's bytes. The runners decode +the input themselves, as above, so what the assertion holds each library to is a string in, the same +string out. Two defects live on the other side of that line and neither is closed by it: the +TypeScript reader refuses an IDF file carrying a byte-order mark, recorded above against +idfkit-js#28, and Python's `save_idf` opens its destination without `newline=""`, so the standard +library translates every line feed to the platform's own convention on the way out. On Windows a +byte-identical write therefore becomes a byte-different file, and the first language's byte-identity +claim is platform-dependent. Both need the reserved `checks/` directory to close. ### Weather retrieval is not covered, and `tier1` does not claim it @@ -95,17 +107,21 @@ build-time warm-up and a run with the network switched off, which is where the s it. The `tier1` tag therefore means "the Tier 1 capabilities this corpus can express", not "every Tier 1 capability", and a green `--tag tier1` is not a statement about weather. -### Writer output is not compared as text, and cannot be +### The two writers' output is not compared against each other, and cannot be The naming register once said this corpus proves that both libraries render the same string for the same model. It does not, and it is not able to. -`runners/compare.md` forbids textual comparison outright, for a good reason set out there: a -formatting difference and a value difference are not the same finding, and a comparator that is -textual anywhere is textual everywhere. The assertion enum has no writer kind. What assertion 3 -does instead is re-parse each library's own IDF output and compare the resulting *document* to the -original, which catches a field that moved or a value that was lost and says nothing about the -bytes in between. +`runners/compare.md` forbids comparing the two libraries textually, for a good reason set out +there: a formatting difference and a value difference are not the same finding, and a comparator +that is textual anywhere is textual everywhere. What assertion 3 does instead is re-parse each +library's own IDF output and compare the resulting *document* to the original, which catches a field +that moved or a value that was lost and says nothing about the bytes in between. + +Assertion 9 is not an exception to this and must not be read as one. It compares a library's output +to **that same library's own input**, never to the other library's output, and its whole content is +that nothing was re-rendered. Rule 1 states the exception as a direction rather than as an assertion +number precisely so the next byte comparison cannot be added by precedent. The bytes do differ, and the differences are real. Round-tripping one file through both writers gives 2-space indentation against 4, insertion order against sorted order, a `!-Generator idfkit` @@ -116,6 +132,25 @@ about output belongs. So a green run is a statement about what each library understood, never about what it typed. +### Three byte-hazard cases declare an assertion neither library can evaluate + +`preserve-empty`, `preserve-comments-only` and `preserve-duplicate-name` declare `preserved-text` +and it is skipped in both languages, because both readers refuse their inputs and a refused read +leaves nothing to write. `preserve-unterminated-final` is skipped on the TypeScript side alone, for +the same reason. + +The first three fail for one cause between them: both libraries resolve their schema from a Version +statement in the file, and an empty file, a file of comments and a file whose duplicate name stops +the read have no readable model. The declared parse outcome is that refusal, which both libraries +agree on exactly, so those cases still earn their keep on assertion 1. + +What they do not yet pin is the writer hazard they were donated for: that a file with nothing to +anchor comes back whole, and that the characters of a statement the read REJECTED survive a write. +The second needs a read that succeeds while rejecting a statement, which neither library offers +today. The assertion is declared rather than dropped so that a library which later reads one of +these files starts being held to the property with no corpus change, and so that the gap is visible +as a skip in every run rather than as an absence nobody sees. + ## Why `known-divergence.toml` ships populated The corpus lands with real, currently failing disagreements already recorded in diff --git a/cases/encoding-latin1-high-bytes/case.toml b/cases/encoding-latin1-high-bytes/case.toml index 74398b6..8e7c8da 100644 --- a/cases/encoding-latin1-high-bytes/case.toml +++ b/cases/encoding-latin1-high-bytes/case.toml @@ -34,4 +34,4 @@ into one. tags = ["encoding"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/encoding-utf8-object-name/case.toml b/cases/encoding-utf8-object-name/case.toml index f7c45ab..0f960bd 100644 --- a/cases/encoding-utf8-object-name/case.toml +++ b/cases/encoding-utf8-object-name/case.toml @@ -33,4 +33,4 @@ reason. tags = ["encoding"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/extensible-empty-group/case.toml b/cases/extensible-empty-group/case.toml index 85e1805..3bfdf93 100644 --- a/cases/extensible-empty-group/case.toml +++ b/cases/extensible-empty-group/case.toml @@ -23,4 +23,4 @@ else in the corpus catches it. tags = ["extensible"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/extensible-partial-group/case.toml b/cases/extensible-partial-group/case.toml index 1f8982a..46cc9ba 100644 --- a/cases/extensible-partial-group/case.toml +++ b/cases/extensible-partial-group/case.toml @@ -25,4 +25,4 @@ is a difference the oracle expectation catches. tags = ["extensible", "positional"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/extensible-schedule-compact-numeric-field/case.toml b/cases/extensible-schedule-compact-numeric-field/case.toml index c4d5361..5a4aa67 100644 --- a/cases/extensible-schedule-compact-numeric-field/case.toml +++ b/cases/extensible-schedule-compact-numeric-field/case.toml @@ -23,4 +23,4 @@ reported difference rather than being rounded away. tags = ["extensible", "numeric", "types"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/extensible-single-vs-multiple/case.toml b/cases/extensible-single-vs-multiple/case.toml index 5bdebc6..c30d177 100644 --- a/cases/extensible-single-vs-multiple/case.toml +++ b/cases/extensible-single-vs-multiple/case.toml @@ -21,4 +21,4 @@ multi-field group can tell those apart. tags = ["extensible"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/extensible-wrapper-key/case.toml b/cases/extensible-wrapper-key/case.toml index 8df2a71..d37bc6f 100644 --- a/cases/extensible-wrapper-key/case.toml +++ b/cases/extensible-wrapper-key/case.toml @@ -26,4 +26,4 @@ numeric coercion. tags = ["extensible"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/naming-blank-vs-absent/case.toml b/cases/naming-blank-vs-absent/case.toml index e634d1a..9d3cfb8 100644 --- a/cases/naming-blank-vs-absent/case.toml +++ b/cases/naming-blank-vs-absent/case.toml @@ -26,4 +26,4 @@ raised it, because no shipped example leaves an optional Name blank. tags = ["naming", "positional"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/naming-synthetic-collision/case.toml b/cases/naming-synthetic-collision/case.toml index a4f27e6..7411f68 100644 --- a/cases/naming-synthetic-collision/case.toml +++ b/cases/naming-synthetic-collision/case.toml @@ -29,4 +29,4 @@ present only to satisfy the oracle's pre-conversion validation, so this case pin tags = ["naming"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/naming-synthetic-key/case.toml b/cases/naming-synthetic-key/case.toml index d141038..be7d93d 100644 --- a/cases/naming-synthetic-key/case.toml +++ b/cases/naming-synthetic-key/case.toml @@ -22,4 +22,4 @@ present only to satisfy the oracle's pre-conversion validation, so this case pin tags = ["naming"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/numeric-autocalculate-casing/case.toml b/cases/numeric-autocalculate-casing/case.toml index 279d183..9540ecd 100644 --- a/cases/numeric-autocalculate-casing/case.toml +++ b/cases/numeric-autocalculate-casing/case.toml @@ -20,4 +20,4 @@ schema enum names, which is not always the word the file used. tags = ["numeric"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/numeric-autosize-casing/case.toml b/cases/numeric-autosize-casing/case.toml index 408caf0..fd26bff 100644 --- a/cases/numeric-autosize-casing/case.toml +++ b/cases/numeric-autosize-casing/case.toml @@ -15,4 +15,4 @@ fails schema validation everywhere at once rather than in one place. tags = ["numeric"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/numeric-integer-field/case.toml b/cases/numeric-integer-field/case.toml index 674c67d..42f089a 100644 --- a/cases/numeric-integer-field/case.toml +++ b/cases/numeric-integer-field/case.toml @@ -17,4 +17,4 @@ would turn every integer field in the corpus red without a single library changi tags = ["numeric", "types"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/numeric-scientific-notation/case.toml b/cases/numeric-scientific-notation/case.toml index 23fecba..e250662 100644 --- a/cases/numeric-scientific-notation/case.toml +++ b/cases/numeric-scientific-notation/case.toml @@ -20,4 +20,4 @@ library and only show up against an external expectation. tags = ["numeric"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/numeric-sentinel-schema-remap/case.toml b/cases/numeric-sentinel-schema-remap/case.toml index 16fa085..dfa138c 100644 --- a/cases/numeric-sentinel-schema-remap/case.toml +++ b/cases/numeric-sentinel-schema-remap/case.toml @@ -29,4 +29,4 @@ and that pointer is the finding. tags = ["numeric", "types"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/numeric-zero-against-blank/case.toml b/cases/numeric-zero-against-blank/case.toml index ed3a2ae..266b4d3 100644 --- a/cases/numeric-zero-against-blank/case.toml +++ b/cases/numeric-zero-against-blank/case.toml @@ -21,4 +21,4 @@ detail of the text, not of the model. tags = ["numeric", "positional"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/positional-extensible-group-trailing-unset/case.toml b/cases/positional-extensible-group-trailing-unset/case.toml index 212bc42..94ce2f4 100644 --- a/cases/positional-extensible-group-trailing-unset/case.toml +++ b/cases/positional-extensible-group-trailing-unset/case.toml @@ -32,4 +32,4 @@ in the corpus rather than one standing in for the rest. tags = ["positional", "extensible"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/positional-trailing-unset-before-extensible/case.toml b/cases/positional-trailing-unset-before-extensible/case.toml index d91dd94..3fb81f6 100644 --- a/cases/positional-trailing-unset-before-extensible/case.toml +++ b/cases/positional-trailing-unset-before-extensible/case.toml @@ -24,4 +24,4 @@ that second failure, since it is a field-order bug and field order is compared o tags = ["positional", "extensible"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/positional-trailing-unset-no-extensible/case.toml b/cases/positional-trailing-unset-no-extensible/case.toml index 4daaffc..639c3c4 100644 --- a/cases/positional-trailing-unset-no-extensible/case.toml +++ b/cases/positional-trailing-unset-no-extensible/case.toml @@ -32,4 +32,4 @@ positional-extensible-group-trailing-unset. tags = ["positional"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/preserve-comment-hazards/case.toml b/cases/preserve-comment-hazards/case.toml new file mode 100644 index 0000000..a28db20 --- /dev/null +++ b/cases/preserve-comment-hazards/case.toml @@ -0,0 +1,16 @@ +title = "A comma and a semicolon inside a trailing comment, neither of which is a delimiter" +why = """ +A comment trailing a value carries both delimiter characters. Neither is a delimiter, because a +comment runs to the end of its line and there are no string literals in this grammar. + +A reader that took either for a delimiter would shift every field after it by one position, so this +is a positional hazard reported as a comment one. For a preserving writer it is a second hazard on +the same characters: the comment lives in the gap between two statements, belongs to no object, and +is reproduced exactly or the file changes in a place no object was edited. + +Donated from the syntax fixtures of the second language. +""" +tags = ["positional"] +energyplus_version = "26.1.0" +truth = "convention" +assertions = ["parse-outcome", "preserved-text"] diff --git a/cases/preserve-comment-hazards/input.idf b/cases/preserve-comment-hazards/input.idf new file mode 100644 index 0000000..3ed84cd --- /dev/null +++ b/cases/preserve-comment-hazards/input.idf @@ -0,0 +1,6 @@ +Version, 26.1; + +Zone, + Zone One, !- Name, which is followed here by a comma; and a semicolon + 0.0, !- Direction of Relative North + 0.0; !- X Origin diff --git a/cases/preserve-comments-only/case.toml b/cases/preserve-comments-only/case.toml new file mode 100644 index 0000000..9a11ccd --- /dev/null +++ b/cases/preserve-comments-only/case.toml @@ -0,0 +1,20 @@ +title = "A file of comments and nothing else, which neither reader will open" +why = """ +Comments, no statements, and therefore no Version. + +For a preserving writer the property is that the output is the input: there is nothing to anchor, +so every character is text that belongs to no object. Neither library can be asked, because both +resolve their schema from a Version statement in the file and both refuse a file that has none. The +declared outcome is that refusal, which the two agree on exactly. + +The preserved-text assertion is declared and is skipped in both languages today. It is declared +rather than dropped so that a library which later reads a versionless file starts being held to the +property without a corpus change, and so that the skip is visible in every run rather than being an +absence nobody sees. The gap is recorded in the README. + +Donated from the syntax fixtures of the second language. +""" +tags = ["malformed"] +energyplus_version = "26.1.0" +truth = "convention" +assertions = ["parse-outcome", "preserved-text"] diff --git a/cases/preserve-comments-only/input.idf b/cases/preserve-comments-only/input.idf new file mode 100644 index 0000000..95fce8e --- /dev/null +++ b/cases/preserve-comments-only/input.idf @@ -0,0 +1,6 @@ +! A model somebody started and never wrote a statement into. +!-Generator IDFEditor 1.51 +!-Option SortedOrder +! +! There is no Version statement here either, so nothing below can be +! resolved against a schema. Classification must still cover every byte. diff --git a/cases/preserve-duplicate-name/case.toml b/cases/preserve-duplicate-name/case.toml new file mode 100644 index 0000000..6433048 --- /dev/null +++ b/cases/preserve-duplicate-name/case.toml @@ -0,0 +1,19 @@ +title = "Two objects of one type declaring the same name, which both readers refuse" +why = """ +Two Zone objects share a name, which the schema forbids and real files contain. + +Both libraries refuse the file, and they agree, which is worth pinning on its own: a duplicate name +is the one malformed shape where the two have historically been asked to differ, and this case +records that they do not. + +The writer hazard this fixture was donated for is the other half, and it is not reachable here: it +is what happens to the characters of a statement the read REJECTED while the read still succeeded, +which is a state neither reader offers, since both stop at the duplicate instead. The preserved-text +assertion is declared and skipped, and the gap is recorded in the README with the other two. + +Donated from the syntax fixtures of the second language. +""" +tags = ["naming"] +energyplus_version = "26.1.0" +truth = "convention" +assertions = ["parse-outcome", "preserved-text"] diff --git a/cases/preserve-duplicate-name/input.idf b/cases/preserve-duplicate-name/input.idf new file mode 100644 index 0000000..c4d8965 --- /dev/null +++ b/cases/preserve-duplicate-name/input.idf @@ -0,0 +1,9 @@ +Version, 26.1; + +Zone, + Zone One, !- Name + 0.0; !- Direction of Relative North + +Zone, + Zone One, !- Name + 90.0; !- Direction of Relative North diff --git a/cases/preserve-edit-no-op/case.toml b/cases/preserve-edit-no-op/case.toml new file mode 100644 index 0000000..eb8803f --- /dev/null +++ b/cases/preserve-edit-no-op/case.toml @@ -0,0 +1,19 @@ +title = "A field written the value it already holds leaves the object as it was" +why = """ +The same input, and an operation that writes the Building's Terrain the value the file already +gives it. Nothing changed, so nothing may be reformatted, and the operations block excludes no +extent: the write is compared against the input in full. + +This case exists because it caught a real defect. The first language cleared its retained source +text on every field write, without comparing, so this operation reformatted the Building and turned +its `0.0` into `0`. It was found by running the library rather than by reading it, and it is +written as a corpus case rather than as that library's unit test for exactly that reason: the +second language got this right for free through a comparing accessor, and a claim that only one +library's own tests hold is a claim the other can drift away from. + +FR-004 and the fourth acceptance scenario of the one-object-diff story. +""" +tags = ["numeric"] +energyplus_version = "26.1.0" +truth = "convention" +assertions = ["parse-outcome", "preserved-text"] diff --git a/cases/preserve-edit-no-op/input.idf b/cases/preserve-edit-no-op/input.idf new file mode 100644 index 0000000..44faee3 --- /dev/null +++ b/cases/preserve-edit-no-op/input.idf @@ -0,0 +1,24 @@ +Version, + 26.1; + +Building, + Scientific Notation, + 0.0, !- North Axis {deg} + City, !- Terrain + 0.04, !- Loads Convergence Tolerance Value {W} + 0.4, !- Temperature Convergence Tolerance Value {deltaC} + FullExterior, !- Solar Distribution + 25, !- Maximum Number of Warmup Days + 6; !- Minimum Number of Warmup Days + +GlobalGeometryRules, + UpperLeftCorner, !- Starting Vertex Position + Counterclockwise, !- Vertex Entry Direction + Relative; !- Coordinate System + +Curve:Linear, + Exponent Forms, !- Name + 1.0E-5, !- Coefficient1 Constant + -2.5e-3, !- Coefficient2 x + -1.5E+2, !- Minimum Value of x + 1.5E2; !- Maximum Value of x diff --git a/cases/preserve-edit-one-field/case.toml b/cases/preserve-edit-one-field/case.toml new file mode 100644 index 0000000..b660698 --- /dev/null +++ b/cases/preserve-edit-one-field/case.toml @@ -0,0 +1,18 @@ +title = "One field changes and exactly one object looks changed" +why = """ +The input is the one from numeric-scientific-notation, unaltered. The operation writes the +Building's Terrain, and the assertion is about every byte that is not the Building's. + +This is SC-002 as a case rather than as a hope. The Curve:Linear object below carries `1.0E-5`, +`-2.5e-3`, `-1.5E+2` and `1.5E2`, four notations no writer reproduces from a parsed number, so a +writer that reformats the whole document to satisfy one edit fails here loudly and immediately. +The comments, the blank lines and the trailing newline are outside every object and are compared +too. + +An existing input rather than a new one, deliberately: the property is about the file the operation +did NOT touch, so the more ordinary the file the better the evidence. +""" +tags = ["numeric"] +energyplus_version = "26.1.0" +truth = "convention" +assertions = ["parse-outcome", "preserved-text"] diff --git a/cases/preserve-edit-one-field/input.idf b/cases/preserve-edit-one-field/input.idf new file mode 100644 index 0000000..44faee3 --- /dev/null +++ b/cases/preserve-edit-one-field/input.idf @@ -0,0 +1,24 @@ +Version, + 26.1; + +Building, + Scientific Notation, + 0.0, !- North Axis {deg} + City, !- Terrain + 0.04, !- Loads Convergence Tolerance Value {W} + 0.4, !- Temperature Convergence Tolerance Value {deltaC} + FullExterior, !- Solar Distribution + 25, !- Maximum Number of Warmup Days + 6; !- Minimum Number of Warmup Days + +GlobalGeometryRules, + UpperLeftCorner, !- Starting Vertex Position + Counterclockwise, !- Vertex Entry Direction + Relative; !- Coordinate System + +Curve:Linear, + Exponent Forms, !- Name + 1.0E-5, !- Coefficient1 Constant + -2.5e-3, !- Coefficient2 x + -1.5E+2, !- Minimum Value of x + 1.5E2; !- Maximum Value of x diff --git a/cases/preserve-edit-remove-and-add/case.toml b/cases/preserve-edit-remove-and-add/case.toml new file mode 100644 index 0000000..7b21167 --- /dev/null +++ b/cases/preserve-edit-remove-and-add/case.toml @@ -0,0 +1,21 @@ +title = "An object removed and another added leave the text between them alone" +why = """ +The same input as preserve-edit-rename. One Construction is removed and one Material:NoMass is +added. + +Two properties in one case, and they fail in opposite directions. A removal must take the object's +own extent and nothing else: the blank line and the comment around it belong to no object and must +survive, and a writer that removes a line too many or too few is reproducing a file the author did +not write. An addition must go to the end, formatted, and must not run onto the last line of a file +whose final statement is where the author left it. + +The removal half is also where the object notation's terms were found to be wrong in the first +language: its preserving write asked the surviving objects whether they were unmodified, and a +removed object is not among them to ask, so the original text came back with the removed object +still in it. That is fixed at the library, and this case is the text format's half of the same +question. +""" +tags = ["references"] +energyplus_version = "26.1.0" +truth = "convention" +assertions = ["parse-outcome", "preserved-text"] diff --git a/cases/preserve-edit-remove-and-add/input.idf b/cases/preserve-edit-remove-and-add/input.idf new file mode 100644 index 0000000..58a6e55 --- /dev/null +++ b/cases/preserve-edit-remove-and-add/input.idf @@ -0,0 +1,24 @@ +! references-case-insensitive-match +Version, + 26.1; !- Version Identifier + +Building, + Conformance Case; !- Name + +GlobalGeometryRules, + UpperLeftCorner, !- Starting Vertex Position + Counterclockwise, !- Vertex Entry Direction + Relative; !- Coordinate System + +Material:NoMass, + Partition Material, !- Name + Rough, !- Roughness + 1.0; !- Thermal Resistance + +Construction, + Upper Case Reference, !- Name + PARTITION MATERIAL; !- Outside Layer + +Construction, + Lower Case Reference, !- Name + partition material; !- Outside Layer diff --git a/cases/preserve-edit-rename/case.toml b/cases/preserve-edit-rename/case.toml new file mode 100644 index 0000000..0b8dc41 --- /dev/null +++ b/cases/preserve-edit-rename/case.toml @@ -0,0 +1,21 @@ +title = "A rename leaves no occurrence of the old name, including in the objects that pointed at it" +why = """ +The input is the one from references-case-insensitive-match, unaltered, in which two Construction +objects name one Material:NoMass, one in upper case and one in lower. The operation renames the +material. + +Three objects are touched by one operation: the material and both constructions, because a rename +rewrites every field that pointed at the old name. A library whose rename bypasses its own change +listener, which is what a rename has to do to avoid recursion, reports only the material as +changed and writes the two constructions from their original characters, producing a file that +loads and names a construction layer that no longer exists. + +That is the only silent failure in this feature: the output is valid, the model is broken, and +nothing in the write path says so. The assertion catches it because the two constructions' extents +are excluded only if the library says they were touched, so a library that failed to say so is +compared against the input over text it did change. +""" +tags = ["references", "naming"] +energyplus_version = "26.1.0" +truth = "convention" +assertions = ["parse-outcome", "preserved-text"] diff --git a/cases/preserve-edit-rename/input.idf b/cases/preserve-edit-rename/input.idf new file mode 100644 index 0000000..58a6e55 --- /dev/null +++ b/cases/preserve-edit-rename/input.idf @@ -0,0 +1,24 @@ +! references-case-insensitive-match +Version, + 26.1; !- Version Identifier + +Building, + Conformance Case; !- Name + +GlobalGeometryRules, + UpperLeftCorner, !- Starting Vertex Position + Counterclockwise, !- Vertex Entry Direction + Relative; !- Coordinate System + +Material:NoMass, + Partition Material, !- Name + Rough, !- Roughness + 1.0; !- Thermal Resistance + +Construction, + Upper Case Reference, !- Name + PARTITION MATERIAL; !- Outside Layer + +Construction, + Lower Case Reference, !- Name + partition material; !- Outside Layer diff --git a/cases/preserve-empty/case.toml b/cases/preserve-empty/case.toml new file mode 100644 index 0000000..7f751d7 --- /dev/null +++ b/cases/preserve-empty/case.toml @@ -0,0 +1,17 @@ +title = "Zero bytes, which every invariant has to hold vacuously" +why = """ +An empty file. The vacuous case, and the one a walk over statements gets wrong by indexing rather +than by reasoning. + +Both readers refuse it, for the same reason they refuse preserve-comments-only: no Version +statement, so no schema resolves. The declared outcome is that refusal and the two agree on it. + +The preserved-text assertion is declared and skipped in both languages, on the same terms as +preserve-comments-only and recorded in the README with it. + +Donated from the syntax fixtures of the second language, where the layer is required to tile it. +""" +tags = ["malformed"] +energyplus_version = "26.1.0" +truth = "convention" +assertions = ["parse-outcome", "preserved-text"] diff --git a/cases/preserve-empty/input.idf b/cases/preserve-empty/input.idf new file mode 100644 index 0000000..e69de29 diff --git a/cases/preserve-line-endings-crlf/case.toml b/cases/preserve-line-endings-crlf/case.toml new file mode 100644 index 0000000..9289d62 --- /dev/null +++ b/cases/preserve-line-endings-crlf/case.toml @@ -0,0 +1,19 @@ +title = "A file written with carriage returns comes back with them" +why = """ +Every line of this file ends CRLF, and nothing about the model is unusual: the twin +preserve-line-endings-mixed carries the same statements under a different convention, so a +difference between the two is a line-ending difference and not a content one. + +A writer that rebuilds a file from its own idea of a line break silently converts every line of a +file it was asked to reproduce. The damage is invisible in a rendered diff and total in a byte +comparison, and it is the reason FR-021 asks for inputs the curated set does not hold: those were +swept from what one engine emits on one platform, and that engine emits the platform's own ending. + +Donated from the syntax fixtures of the second language, where it is read as bytes and never +through anything that normalises a line ending. A tool that helpfully converts this file has +destroyed it rather than tidied it. +""" +tags = ["encoding"] +energyplus_version = "26.1.0" +truth = "convention" +assertions = ["parse-outcome", "preserved-text"] diff --git a/cases/preserve-line-endings-crlf/input.idf b/cases/preserve-line-endings-crlf/input.idf new file mode 100644 index 0000000..70ac6f4 --- /dev/null +++ b/cases/preserve-line-endings-crlf/input.idf @@ -0,0 +1,8 @@ +Version, 26.1; + +Zone, + Zone One, !- Name + 0.0, !- Direction of Relative North + 0.0; !- X Origin + +Timestep, 6; diff --git a/cases/preserve-line-endings-mixed/case.toml b/cases/preserve-line-endings-mixed/case.toml new file mode 100644 index 0000000..ddb138c --- /dev/null +++ b/cases/preserve-line-endings-mixed/case.toml @@ -0,0 +1,17 @@ +title = "A file mixing both line-ending conventions keeps each line's own" +why = """ +The same statements as preserve-line-endings-crlf, with the two conventions alternating line by +line. Real files reach this state through an editor that appends with one convention to a file +written with the other, and they are common. + +This is the case a writer passes by accident and fails by design. Normalising to either convention +reproduces most of the file correctly, so a spot check looks right; the assertion compares bytes +and the difference is one character on every second line. + +Donated from the syntax fixtures of the second language. Do not open this file in anything that +normalises line endings on save. +""" +tags = ["encoding"] +energyplus_version = "26.1.0" +truth = "convention" +assertions = ["parse-outcome", "preserved-text"] diff --git a/cases/preserve-line-endings-mixed/input.idf b/cases/preserve-line-endings-mixed/input.idf new file mode 100644 index 0000000..35b3e66 --- /dev/null +++ b/cases/preserve-line-endings-mixed/input.idf @@ -0,0 +1,8 @@ +Version, 26.1; + +Zone, + Zone One, !- Name + 0.0, !- Direction of Relative North + 0.0; !- X Origin + +Timestep, 6; diff --git a/cases/preserve-no-trailing-newline/case.toml b/cases/preserve-no-trailing-newline/case.toml new file mode 100644 index 0000000..5f80809 --- /dev/null +++ b/cases/preserve-no-trailing-newline/case.toml @@ -0,0 +1,18 @@ +title = "A file whose last line has no line feed does not gain one" +why = """ +The last character of this file is the `n` of a trailing comment. There is no line feed after it, +and a preserving write must not add one. + +The curated corpus holds no such file, and cannot: its inputs were swept from what one engine +emits, and that engine ends every file with a line feed. So a writer that appends a newline +"to be safe" passes every existing case and is wrong on every file a human editor produced with +that setting off. Nothing but a fixture like this one notices, which is exactly what FR-021 asks +for. + +Written for the preserving writer and donated the other way, into the second language's syntax +fixtures, where it is also held to feature 005's reconstruction invariant. +""" +tags = ["encoding"] +energyplus_version = "26.1.0" +truth = "convention" +assertions = ["parse-outcome", "preserved-text"] diff --git a/cases/preserve-no-trailing-newline/input.idf b/cases/preserve-no-trailing-newline/input.idf new file mode 100644 index 0000000..df7d358 --- /dev/null +++ b/cases/preserve-no-trailing-newline/input.idf @@ -0,0 +1,6 @@ +Version, 26.1; + +Zone, + Zone One, !- Name + 0.0, !- Direction of Relative North + 0.0; !- X Origin \ No newline at end of file diff --git a/cases/preserve-unterminated-final/case.toml b/cases/preserve-unterminated-final/case.toml new file mode 100644 index 0000000..b828f82 --- /dev/null +++ b/cases/preserve-unterminated-final/case.toml @@ -0,0 +1,24 @@ +title = "A file that ends inside its last object, which one parser drops in silence" +why = """ +The last statement has no terminating semicolon and runs to end of input. + +The declared outcome is failure, on the same reasoning the corpus already settled in +malformed-truncated-object: losing an object in silence is worse than refusing the file. The +TypeScript core refuses it, reporting an unterminated object. Python accepts the file and discards +the Zone without a word, which is carried in known-divergence.toml against the same issue as that +case, idfkit 187, because it is the same root cause in the same pattern. + +The preserving half is what this case adds. Python's writer reproduces the file byte for byte +anyway, because the text of the statement it dropped is still a text run in the syntax tree, so the +assertion is evaluated and passes there. It is skipped in the second language, which never got a +document. That leaves the writer hazard this fixture was donated for, that an untouched +unterminated statement is copied and no terminator is invented, unpinned in the second language +until its reader stops refusing the file. That gap is recorded in the README rather than left for a +reader to infer from a skip. + +Donated from the syntax fixtures of the second language. +""" +tags = ["malformed"] +energyplus_version = "26.1.0" +truth = "convention" +assertions = ["parse-outcome", "preserved-text"] diff --git a/cases/preserve-unterminated-final/input.idf b/cases/preserve-unterminated-final/input.idf new file mode 100644 index 0000000..8443890 --- /dev/null +++ b/cases/preserve-unterminated-final/input.idf @@ -0,0 +1,6 @@ +Version, 26.1; + +Zone, + Zone One, + 0.0, + 0.0 diff --git a/cases/preserve-value-across-lines/case.toml b/cases/preserve-value-across-lines/case.toml new file mode 100644 index 0000000..cc23d3d --- /dev/null +++ b/cases/preserve-value-across-lines/case.toml @@ -0,0 +1,19 @@ +title = "A field value written across two lines keeps both of them" +why = """ +`My` on one line and `Zone,` on the next are one field value, because a line break is whitespace +inside a field and closes nothing. + +A writer that reproduces this object from its parsed value writes `My Zone` on one line, which is +the same model and a different file. Preservation is what keeps it, and the case is here because +the shape is legal, occurs in hand-edited files, and is invisible to every assertion that compares +values. + +Donated from the syntax fixtures of the second language, with a Version statement prepended and +nothing else changed. Both libraries resolve their schema from the file, so the donor as it stands +is refused by both readers before the hazard is ever reached; adding the statement is what makes +the case evaluate, and it leaves the two-line value exactly as donated. +""" +tags = ["positional"] +energyplus_version = "26.1.0" +truth = "convention" +assertions = ["parse-outcome", "preserved-text"] diff --git a/cases/preserve-value-across-lines/input.idf b/cases/preserve-value-across-lines/input.idf new file mode 100644 index 0000000..12056cb --- /dev/null +++ b/cases/preserve-value-across-lines/input.idf @@ -0,0 +1,6 @@ +Version, 26.1; + +Zone, + My + Zone, + 0.0; diff --git a/cases/references-case-insensitive-match/case.toml b/cases/references-case-insensitive-match/case.toml index 1d0d870..e3db527 100644 --- a/cases/references-case-insensitive-match/case.toml +++ b/cases/references-case-insensitive-match/case.toml @@ -42,4 +42,4 @@ the target's casing cannot be mistaken for the file having been written that way tags = ["references"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/references-dangling-name/case.toml b/cases/references-dangling-name/case.toml index d73bf87..697dffd 100644 --- a/cases/references-dangling-name/case.toml +++ b/cases/references-dangling-name/case.toml @@ -32,4 +32,4 @@ involved, and any difference reported against this case is about the reference a tags = ["references"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/references-self-referential/case.toml b/cases/references-self-referential/case.toml index 331c7d1..d36e791 100644 --- a/cases/references-self-referential/case.toml +++ b/cases/references-self-referential/case.toml @@ -39,4 +39,4 @@ it converts. tags = ["references"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/types-choice-field-casing/case.toml b/cases/types-choice-field-casing/case.toml index 11b8476..9f54d97 100644 --- a/cases/types-choice-field-casing/case.toml +++ b/cases/types-choice-field-casing/case.toml @@ -30,4 +30,4 @@ the schema's choice list case-insensitively and emit the schema's spelling. tags = ["types"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/types-object-type-name-casing/case.toml b/cases/types-object-type-name-casing/case.toml index 90d38ce..51422cc 100644 --- a/cases/types-object-type-name-casing/case.toml +++ b/cases/types-object-type-name-casing/case.toml @@ -26,4 +26,4 @@ types-choice-field-casing. tags = ["types"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/versions-22-1-0/case.toml b/cases/versions-22-1-0/case.toml index 2751c50..cdba3c2 100644 --- a/cases/versions-22-1-0/case.toml +++ b/cases/versions-22-1-0/case.toml @@ -20,4 +20,4 @@ purpose, so this case never fires on the separate choice canonicalisation hazard tags = ["versions"] energyplus_version = "22.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/versions-22-2-0/case.toml b/cases/versions-22-2-0/case.toml index 5b1a16d..c22134a 100644 --- a/cases/versions-22-2-0/case.toml +++ b/cases/versions-22-2-0/case.toml @@ -20,4 +20,4 @@ purpose, so this case never fires on the separate choice canonicalisation hazard tags = ["versions"] energyplus_version = "22.2.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/versions-23-1-0/case.toml b/cases/versions-23-1-0/case.toml index 123fecc..b3830fa 100644 --- a/cases/versions-23-1-0/case.toml +++ b/cases/versions-23-1-0/case.toml @@ -20,4 +20,4 @@ purpose, so this case never fires on the separate choice canonicalisation hazard tags = ["versions"] energyplus_version = "23.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/versions-23-2-0/case.toml b/cases/versions-23-2-0/case.toml index 4d06100..fe3039f 100644 --- a/cases/versions-23-2-0/case.toml +++ b/cases/versions-23-2-0/case.toml @@ -20,4 +20,4 @@ purpose, so this case never fires on the separate choice canonicalisation hazard tags = ["versions"] energyplus_version = "23.2.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/versions-24-1-0/case.toml b/cases/versions-24-1-0/case.toml index 8729880..1751b4f 100644 --- a/cases/versions-24-1-0/case.toml +++ b/cases/versions-24-1-0/case.toml @@ -20,4 +20,4 @@ purpose, so this case never fires on the separate choice canonicalisation hazard tags = ["versions"] energyplus_version = "24.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/versions-24-2-0/case.toml b/cases/versions-24-2-0/case.toml index 4a536d7..7db8c94 100644 --- a/cases/versions-24-2-0/case.toml +++ b/cases/versions-24-2-0/case.toml @@ -20,4 +20,4 @@ purpose, so this case never fires on the separate choice canonicalisation hazard tags = ["versions"] energyplus_version = "24.2.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/versions-25-1-0/case.toml b/cases/versions-25-1-0/case.toml index d6d3c12..ee47391 100644 --- a/cases/versions-25-1-0/case.toml +++ b/cases/versions-25-1-0/case.toml @@ -20,4 +20,4 @@ purpose, so this case never fires on the separate choice canonicalisation hazard tags = ["versions"] energyplus_version = "25.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/versions-25-2-0/case.toml b/cases/versions-25-2-0/case.toml index b525a85..3d060cf 100644 --- a/cases/versions-25-2-0/case.toml +++ b/cases/versions-25-2-0/case.toml @@ -20,4 +20,4 @@ purpose, so this case never fires on the separate choice canonicalisation hazard tags = ["versions"] energyplus_version = "25.2.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/versions-26-1-0/case.toml b/cases/versions-26-1-0/case.toml index e065f41..dd46b41 100644 --- a/cases/versions-26-1-0/case.toml +++ b/cases/versions-26-1-0/case.toml @@ -20,4 +20,4 @@ purpose, so this case never fires on the separate choice canonicalisation hazard tags = ["versions"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/versions-9-5-0/case.toml b/cases/versions-9-5-0/case.toml index ee2f5ec..4cb3d3a 100644 --- a/cases/versions-9-5-0/case.toml +++ b/cases/versions-9-5-0/case.toml @@ -20,4 +20,4 @@ purpose, so this case never fires on the separate choice canonicalisation hazard tags = ["versions"] energyplus_version = "9.5.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/versions-9-6-0/case.toml b/cases/versions-9-6-0/case.toml index b69ff17..f6fd366 100644 --- a/cases/versions-9-6-0/case.toml +++ b/cases/versions-9-6-0/case.toml @@ -20,4 +20,4 @@ purpose, so this case never fires on the separate choice canonicalisation hazard tags = ["versions"] energyplus_version = "9.6.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/versions-canonical-string/case.toml b/cases/versions-canonical-string/case.toml index 50590a3..66cde3a 100644 --- a/cases/versions-canonical-string/case.toml +++ b/cases/versions-canonical-string/case.toml @@ -47,4 +47,4 @@ purpose, so this case never fires on the separate choice canonicalisation hazard tags = ["versions"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/cases/versions-late-version-object/case.toml b/cases/versions-late-version-object/case.toml index a6a6357..da122e6 100644 --- a/cases/versions-late-version-object/case.toml +++ b/cases/versions-late-version-object/case.toml @@ -22,4 +22,4 @@ regenerated with a header that crosses the new one, and the header lead comment tags = ["versions"] energyplus_version = "26.1.0" truth = "oracle" -assertions = ["parse-outcome", "epjson", "round-trip"] +assertions = ["parse-outcome", "epjson", "round-trip", "preserved-text"] diff --git a/known-divergence.toml b/known-divergence.toml index c24dd05..4f90bb8 100644 --- a/known-divergence.toml +++ b/known-divergence.toml @@ -15,12 +15,16 @@ # either side alone turns its entries stale, which the runner reports as a failure, so those four # issues must land together. # -# Counts, against idfkit 0.12.2 and @idfkit/core 0.0.0, EnergyPlus 26.1.0: 20 entries over 12 -# issues and 12 cases, 14 against python and 6 against typescript, on a corpus of 56 cases and 148 +# Counts, against idfkit 1.0.0rc2 and @idfkit/core 0.2.0, EnergyPlus 26.1.0: 68 entries over 13 +# issues and 57 cases, 16 against python and 52 against typescript, on a corpus of 69 cases and 211 # assertions. Four entries went away in e60b74d, when both libraries started agreeing about blank -# names. This header stated 22 entries over 47 cases until 2026-09-04, having been written before -# those four were removed and before the 48th case landed; it is recounted from the file and from -# the runner's own tally rather than adjusted, and it is recounted again whenever a case lands. +# names. This header stated 22 entries over 47 cases until 2026-09-04, and 20 over 12 cases until +# the preserved-text assertion landed; it is recounted from the file and from the runner's own +# tally rather than adjusted, and it is recounted again whenever a case lands. +# +# 46 of the 52 typescript entries are the one absence recorded 46 times, which is what the (case, +# library, assertion) unit of exception costs when a whole capability is missing. They go together, +# in the change that lands the writer, and the count corrects itself here when they do. # # The tier1-introspection-field-shapes entry was removed on 2026-09-04, in the change that closed # the introspection capability. It was the one entry that recorded no bug: it pinned a divergence @@ -222,3 +226,475 @@ issue = "https://github.com/idfkit/idfkit/issues/185" observed = '''Nothing to compare: the read raised VersionNotFoundError before any document existed to write back. Downstream of the parse-outcome entry on the same case.''' expected = '''Re-parsing Python's own IDF output deep-equals the original document, Version object included.''' +# --------------------------------------------------------------------------- +# preserved-text: the second language has no preserving write yet +# --------------------------------------------------------------------------- +# +# One entry per case carrying the assertion, all naming idfkit-js 12, which is the issue the parity +# ledger already points at for this capability. They are mechanical and differ only in the case +# name, because the corpus has no way to say "this library lacks this capability" once: its unit of +# exception is the (case, library, assertion) triple, and that granularity is what makes a stale +# entry detectable. They are removed in one change, the change that lands the writer. +# +# The assertion was cut at conformance-2026.9 while the second language still failed it, which is +# FR-025 and is what makes the corpus the specification of the behaviour rather than a check +# applied afterwards. +# +# Three cases carrying the assertion have no entry, because they are not failing: preserve-empty, +# preserve-comments-only and preserve-duplicate-name declare a parse failure both libraries agree +# on, so there is no document and the assertion is skipped in both. preserve-unterminated-final has +# none either, for the same reason on the TypeScript side alone. + +[[divergence]] +case = "encoding-latin1-high-bytes" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "encoding-utf8-object-name" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "extensible-empty-group" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "extensible-partial-group" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "extensible-schedule-compact-numeric-field" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "extensible-single-vs-multiple" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "extensible-wrapper-key" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "naming-blank-vs-absent" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "naming-synthetic-collision" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "naming-synthetic-key" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "numeric-autocalculate-casing" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "numeric-autosize-casing" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "numeric-integer-field" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "numeric-scientific-notation" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "numeric-sentinel-schema-remap" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "numeric-zero-against-blank" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "positional-extensible-group-trailing-unset" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "positional-trailing-unset-before-extensible" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "positional-trailing-unset-no-extensible" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "preserve-comment-hazards" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "preserve-edit-no-op" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "preserve-edit-one-field" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "preserve-edit-remove-and-add" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "preserve-edit-rename" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "preserve-line-endings-crlf" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "preserve-line-endings-mixed" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "preserve-no-trailing-newline" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "preserve-value-across-lines" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "references-case-insensitive-match" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "references-dangling-name" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "references-self-referential" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "types-choice-field-casing" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "types-object-type-name-casing" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "versions-22-1-0" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "versions-22-2-0" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "versions-23-1-0" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "versions-23-2-0" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "versions-24-1-0" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "versions-24-2-0" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "versions-25-1-0" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "versions-25-2-0" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "versions-26-1-0" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "versions-9-5-0" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "versions-9-6-0" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "versions-canonical-string" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +[[divergence]] +case = "versions-late-version-object" +library = "typescript" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit-js/issues/12" +observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment +states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' +expected = '''The written text equals the input byte for byte.''' + +# --------------------------------------------------------------------------- +# preserved-text and parse-outcome: two first-language bugs that were already tracked +# --------------------------------------------------------------------------- +# +# FR-025 forbids recording a finding the preserving writer exposes in the first language as an +# accepted divergence: that language already claims this capability, so a finding there is fixed +# rather than accepted. Four were found and four were fixed, in the release the corpus runs +# against: the no-op field write, the object-notation removal, the missing version_first in the +# refusal guard, and the silent drop of a control set on the default path. +# +# These two are not that. They are idfkit 185 and idfkit 187, both open before this feature and +# both already carrying entries on other assertions of the same cases. A new assertion and a new +# case surface the same two bugs a third and a fourth time, and an entry that names the issue +# already tracking them is the honest record. Fixing 185 and 187 makes both stale together with +# their siblings. + +[[divergence]] +case = "versions-late-version-object" +library = "python" +assertion = "preserved-text" +issue = "https://github.com/idfkit/idfkit/issues/185" +observed = '''The read fails before there is anything to preserve, so the assertion has nothing to +compare: "the input did not parse, so the library produced nothing to compare". The same refusal is +already recorded against this case on parse-outcome, epjson and round-trip.''' +expected = '''The Version statement is found behind the ten-kilobyte comment header, the file is +read, and the preserving write of it equals it byte for byte.''' + +[[divergence]] +case = "preserve-unterminated-final" +library = "python" +assertion = "parse-outcome" +issue = "https://github.com/idfkit/idfkit/issues/187" +observed = '''Reading succeeds and returns a document holding Version alone. The unterminated Zone +is discarded with no diagnostic of any kind, which is the same root cause and the same pattern as +malformed-truncated-object: _OBJECT_PATTERN matches only text that reaches a semicolon, so an +unterminated tail matches nothing at all.''' +expected = '''Reading fails, as it does in the TypeScript core, which reports an unterminated +object. Losing an object in silence is the hazard, and the corpus settled that reasoning in +malformed-truncated-object before this case existed.''' diff --git a/manifest.json b/manifest.json index 0f9379c..633e421 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "$schema": "./manifest.schema.json", "schema_version": 1, - "corpus_level": "conformance-2026.8", + "corpus_level": "conformance-2026.9", "oracle": [ { "id": "encoding-latin1-high-bytes", @@ -13,7 +13,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -29,7 +30,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -45,7 +47,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -62,7 +65,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -80,7 +84,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -96,7 +101,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -112,7 +118,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -129,7 +136,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -145,7 +153,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -161,7 +170,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -177,7 +187,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -193,7 +204,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -210,7 +222,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -226,7 +239,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -243,7 +257,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -260,7 +275,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -277,7 +293,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -294,7 +311,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -310,7 +328,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -326,7 +345,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -342,7 +362,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -358,7 +379,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -374,7 +396,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -390,7 +413,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -406,7 +430,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -422,7 +447,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -438,7 +464,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -454,7 +481,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -470,7 +498,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -486,7 +515,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -502,7 +532,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -518,7 +549,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -534,7 +566,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -550,7 +583,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -566,7 +600,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -582,7 +617,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -598,7 +634,8 @@ "assertions": [ "parse-outcome", "epjson", - "round-trip" + "round-trip", + "preserved-text" ], "input": "input.idf", "parse_outcome": "success", @@ -906,6 +943,231 @@ "writer_options": { "ordering": "source" } + }, + { + "id": "preserve-line-endings-crlf", + "title": "A file written with carriage returns comes back with them", + "tags": [ + "encoding" + ], + "energyplus_version": "26.1.0", + "assertions": [ + "parse-outcome", + "preserved-text" + ], + "input": "input.idf", + "parse_outcome": "success" + }, + { + "id": "preserve-line-endings-mixed", + "title": "A file mixing both line-ending conventions keeps each line's own", + "tags": [ + "encoding" + ], + "energyplus_version": "26.1.0", + "assertions": [ + "parse-outcome", + "preserved-text" + ], + "input": "input.idf", + "parse_outcome": "success" + }, + { + "id": "preserve-no-trailing-newline", + "title": "A file whose last line has no line feed does not gain one", + "tags": [ + "encoding" + ], + "energyplus_version": "26.1.0", + "assertions": [ + "parse-outcome", + "preserved-text" + ], + "input": "input.idf", + "parse_outcome": "success" + }, + { + "id": "preserve-unterminated-final", + "title": "A file that ends inside its last object, which one parser drops in silence", + "tags": [ + "malformed" + ], + "energyplus_version": "26.1.0", + "assertions": [ + "parse-outcome", + "preserved-text" + ], + "input": "input.idf", + "parse_outcome": "failure" + }, + { + "id": "preserve-comment-hazards", + "title": "A comma and a semicolon inside a trailing comment, neither of which is a delimiter", + "tags": [ + "positional" + ], + "energyplus_version": "26.1.0", + "assertions": [ + "parse-outcome", + "preserved-text" + ], + "input": "input.idf", + "parse_outcome": "success" + }, + { + "id": "preserve-value-across-lines", + "title": "A field value written across two lines keeps both of them", + "tags": [ + "positional" + ], + "energyplus_version": "26.1.0", + "assertions": [ + "parse-outcome", + "preserved-text" + ], + "input": "input.idf", + "parse_outcome": "success" + }, + { + "id": "preserve-comments-only", + "title": "A file of comments and nothing else, which neither reader will open", + "tags": [ + "malformed" + ], + "energyplus_version": "26.1.0", + "assertions": [ + "parse-outcome", + "preserved-text" + ], + "input": "input.idf", + "parse_outcome": "failure" + }, + { + "id": "preserve-empty", + "title": "Zero bytes, which every invariant has to hold vacuously", + "tags": [ + "malformed" + ], + "energyplus_version": "26.1.0", + "assertions": [ + "parse-outcome", + "preserved-text" + ], + "input": "input.idf", + "parse_outcome": "failure" + }, + { + "id": "preserve-duplicate-name", + "title": "Two objects of one type declaring the same name, which both readers refuse", + "tags": [ + "naming" + ], + "energyplus_version": "26.1.0", + "assertions": [ + "parse-outcome", + "preserved-text" + ], + "input": "input.idf", + "parse_outcome": "failure" + }, + { + "id": "preserve-edit-one-field", + "title": "One field changes and exactly one object looks changed", + "tags": [ + "numeric" + ], + "energyplus_version": "26.1.0", + "assertions": [ + "parse-outcome", + "preserved-text" + ], + "input": "input.idf", + "parse_outcome": "success", + "operations": [ + { + "op": "set-field", + "type": "Building", + "name": "Scientific Notation", + "field": "terrain", + "value": "Suburbs" + } + ] + }, + { + "id": "preserve-edit-no-op", + "title": "A field written the value it already holds leaves the object as it was", + "tags": [ + "numeric" + ], + "energyplus_version": "26.1.0", + "assertions": [ + "parse-outcome", + "preserved-text" + ], + "input": "input.idf", + "parse_outcome": "success", + "operations": [ + { + "op": "set-field", + "type": "Building", + "name": "Scientific Notation", + "field": "terrain", + "value": "City" + } + ] + }, + { + "id": "preserve-edit-rename", + "title": "A rename leaves no occurrence of the old name, including in the objects that pointed at it", + "tags": [ + "references", + "naming" + ], + "energyplus_version": "26.1.0", + "assertions": [ + "parse-outcome", + "preserved-text" + ], + "input": "input.idf", + "parse_outcome": "success", + "operations": [ + { + "op": "rename", + "type": "Material:NoMass", + "name": "Partition Material", + "to": "Partition Material Renamed" + } + ] + }, + { + "id": "preserve-edit-remove-and-add", + "title": "An object removed and another added leave the text between them alone", + "tags": [ + "references" + ], + "energyplus_version": "26.1.0", + "assertions": [ + "parse-outcome", + "preserved-text" + ], + "input": "input.idf", + "parse_outcome": "success", + "operations": [ + { + "op": "remove", + "type": "Construction", + "name": "Lower Case Reference" + }, + { + "op": "add", + "type": "Material:NoMass", + "name": "Added Material", + "fields": { + "roughness": "Smooth", + "thermal_resistance": "2.5" + } + } + ] } ] } diff --git a/manifest.schema.json b/manifest.schema.json index c92d42b..2046fc9 100644 --- a/manifest.schema.json +++ b/manifest.schema.json @@ -59,9 +59,9 @@ ] }, "assertion": { - "description": "One assertion. 'diagnostics' is accepted and skipped by the runners until phase two. 'validation', 'introspection' and 'docs-url' are the Tier 1 assertions, which have no oracle behind them; see runners/compare.md. 'type-lookup' has no oracle either and asks a different question from every other assertion: not what a library made of a file, but what it returns when a caller names an object type.", + "description": "One assertion. 'diagnostics' is accepted and skipped by the runners until phase two. 'validation', 'introspection' and 'docs-url' are the Tier 1 assertions, which have no oracle behind them; see runners/compare.md. 'type-lookup' has no oracle either and asks a different question from every other assertion: not what a library made of a file, but what it returns when a caller names an object type. 'preserved-text' is the one byte-level assertion and is not 'round-trip' under another name: round-trip re-reads a library's own output and compares documents, saying nothing about the bytes in between, and it writes with preservation explicitly OFF for that reason. preserved-text asserts the echo that would make round-trip trivially true, deliberately.", "type": "string", - "enum": ["parse-outcome", "epjson", "round-trip", "diagnostics", "validation", "introspection", "docs-url", "type-lookup"] + "enum": ["parse-outcome", "epjson", "round-trip", "diagnostics", "validation", "introspection", "docs-url", "type-lookup", "preserved-text"] }, "case": { "description": "One case entry. Shared by both sections; the section decides whether 'expected' is required or forbidden.", @@ -134,6 +134,12 @@ "type": "string", "const": "expected.type-lookup.json" }, + "operations": { + "description": "Changes to apply to the document between reading and writing, in list order. Language-neutral: each runner maps the four kinds onto its own library's mutators, exactly as 'writer_options' already does for the five writer controls. ABSENT MEANS READ ONLY, which is what all 56 pre-existing cases mean and must keep meaning. A case declaring operations alongside 'preserved-text' narrows that assertion to the property FR-023 states: every byte outside the extents of the objects these operations touched equals the input. The four kinds are the four the touched record has to get right, and no more; a fifth is added when a claim needs it, not in advance.", + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/operation" } + }, "writer_options": { "description": "Writer controls to apply before re-reading, in the 'round-trip' assertion. Language-neutral names: each runner maps them onto its own writer's option. Absent means every writer default, which is what all 48 pre-existing cases mean and must keep meaning. A case that sets one is asserting FR-019: a document written under that control re-reads to the same structure.", "type": "object", @@ -175,6 +181,64 @@ { "$ref": "#/$defs/expectationRule/typeLookup" } ] }, + "operation": { + "description": "One change to the document, applied before the write. 'type' and 'name' identify the object in language-neutral terms; a name is required even for a type that carries none, in which case it is the collection key the runner looks the object up by.", + "type": "object", + "required": ["op", "type", "name"], + "additionalProperties": false, + "properties": { + "op": { + "description": "Which change. 'set-field' writes one field; 'rename' changes an object's name and retargets every reference to it; 'remove' takes an object out of the document; 'add' puts a new one in.", + "type": "string", + "enum": ["set-field", "rename", "remove", "add"] + }, + "type": { + "description": "The object's type name, as the schema spells it.", + "type": "string", + "minLength": 1 + }, + "name": { + "description": "The object's name. For 'add', the name the new object takes.", + "type": "string" + }, + "field": { + "description": "The epJSON field name to write. Required by 'set-field', forbidden otherwise.", + "type": "string", + "minLength": 1 + }, + "value": { + "description": "The value to write, as text. Each runner coerces it the way its own reader coerces a value read from a file, so a case says what a file would have said. Required by 'set-field', forbidden otherwise.", + "type": "string" + }, + "to": { + "description": "The new name. Required by 'rename', forbidden otherwise.", + "type": "string", + "minLength": 1 + }, + "fields": { + "description": "Field values for the new object, as text. Optional on 'add', forbidden otherwise.", + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "allOf": [ + { + "if": { "properties": { "op": { "const": "set-field" } }, "required": ["op"] }, + "then": { "required": ["field", "value"] }, + "else": { "allOf": [{ "not": { "required": ["field"] } }, { "not": { "required": ["value"] } }] } + }, + { + "if": { "properties": { "op": { "const": "rename" } }, "required": ["op"] }, + "then": { "required": ["to"] }, + "else": { "not": { "required": ["to"] } } + }, + { + "if": { "properties": { "op": { "const": "add" } }, "required": ["op"] }, + "then": true, + "else": { "not": { "required": ["fields"] } } + } + ] + }, "expectationRule": { "description": "One rule per assertion that reads an expectation file: declaring the assertion requires the file, and not declaring it forbids naming one. Kept as parallel entries rather than one parameterised rule because JSON Schema has no way to express the parameter.", "diagnostics": { diff --git a/runners/compare.md b/runners/compare.md index b6c5005..7e9e3a4 100644 --- a/runners/compare.md +++ b/runners/compare.md @@ -51,6 +51,19 @@ surface real findings, so anything that generates noise at this volume defeats i The corollary matters as much: a comparator that is textual anywhere is textual everywhere. Do not compare a nested subtree by serializing it, not even for a fast path or a cheap hash. +**One bounded exception, and its boundary.** Assertion 9, `preserved-text`, compares bytes. It is +permitted to because it compares a library's output to that same library's own input, and never to +the other library's output. The reason above has no purchase on that comparison: it is about two +libraries rendering one value as two texts, and this assertion's whole content is that nothing was +re-rendered, so no notation is generated to disagree about. The corollary stands unchanged for +every comparison between the two libraries, which is every other assertion in this file. A +comparator may be textual in that direction only, and a second textual comparison requires a second +direction to be stated here first. + +The boundary is a **direction**, not an assertion number, deliberately. An exception keyed to +"assertion 9" invites the next byte comparison to be added by precedent. An exception keyed to a +direction cannot be extended without writing down the new direction and defending it. + ### 2. Numbers epJSON has one number type. Python distinguishes `int` from `float`, JavaScript has neither @@ -168,6 +181,36 @@ missing and extra kinds. while both stay green. Extending this list is an amendment to this file, made before either comparator changes. +### 8. A byte comparison reports an offset and a window, never a whole file + +Assertion 9 alone compares text, on the terms rule 1's exception sets. This rule states how it +reports, so that the one textual comparator in this file cannot fall back on the shape of a diff. + +A comparison of two texts reports, for each differing region: + +- the **offset** of the first differing byte in that region, counted from the start of the text; +- the **line and column** that offset falls on, both counting from one, by the same convention + every other position in this corpus uses; +- a **bounded window** of each side around the offset, at most 80 characters, with the window's own + start offset so a reader can place it. + +It never prints either text whole, and it never emits a line diff. + +**Reason.** This assertion's failures are one character wide by nature: a lost trailing newline, a +`3.000` come back as `3`, a line ending translated. A diff of two 600 KB files is not a finding a +maintainer can act on, and printing one hides the single character that is actually wrong inside +everything that is right. + +`--max-differences` bounds this comparator as it bounds every other, counting **differing regions** +rather than differing characters. Two texts that diverge completely from offset zero are one region, +not six hundred thousand differences. + +A case declaring operations narrows what is compared rather than how it reports: the regions of the +objects those operations touched are excluded from the comparison, because their text is the +library's ordinary formatting and legitimately differs between the two. Everything outside them is +compared as above. A runner that cannot determine a touched object's extent reports an error rather +than a pass. + ## Reporting The comparator returns **every** difference it finds, not the first. The runner may truncate what it @@ -207,6 +250,7 @@ Every difference reports both values verbatim as parsed, never reformatted, so t | 6 | Type descriptions match `expected.introspection.json` | now | | 7 | Documentation addresses match `expected.docs-url.json` | now | | 8 | Collection lookups by object type name match `expected.type-lookup.json` | now | +| 9 | Preserved text: a preserving write of the case's own source text equals that source text, byte for byte | now | Rules 1, 2, 3, 5, 6, and 7 apply to assertions 2 and 3. Rule 4 applies to assertion 3 only. Assertion 1 compares an outcome, not a value, and uses no rule but rule 6 for reading the input. @@ -214,6 +258,15 @@ Assertions 5 to 7 are governed by [The Tier 1 assertions](#the-tier-1-assertions every rule except rule 4: they compare values that have no IDF field order. Assertion 8 is governed by [Assertion 8: type lookup](#assertion-8-type-lookup) below. +Assertion 9 is governed by rule 1's bounded exception and by rule 8, and by no other rule here: +it compares text rather than values, so the rules about numbers, key order, field order and +unordered collections have nothing to compare. Rule 6 still applies to reading the input, as it +does everywhere. It is **not** assertion 3 under another name: assertion 3 re-reads a library's own +output and compares documents, saying nothing about the bytes in between, and it writes with +preservation explicitly OFF because the lossless path would otherwise echo the source back and make +it trivially true. Assertion 9 is that echo, asserted deliberately. The two must not be merged and +their names must not invite it. + Assertions 1 to 3 needed no changes inside either library, which is why they shipped first. Assertion 4 did need changes, which is why it was deferred: `ParseDiagnostic` in JavaScript carried free-text `message`, `line` and `typeName` with no code, and Python raised typed exceptions diff --git a/runners/compare.mjs b/runners/compare.mjs index 629e834..8ee37cf 100644 --- a/runners/compare.mjs +++ b/runners/compare.mjs @@ -25,6 +25,10 @@ * Rule 5, strings: `compareScalar`, one `!==`, no normalisation of any kind. * Rule 6, encoding: the runner's, not this module's. Values arrive already decoded. * Rule 7, unordered collections: `compareUnordered`. + * Rule 8, a byte comparison's report: `comparePreservedText` and `TextRegion`. The one + * textual comparison in this module, permitted by rule 1's bounded exception and confined + * to the direction that exception names: a library's own output against that library's + * own input, never against the other library's output. * * Orientation, fixed and never swapped: `left` is the library under test, `right` is the * expectation. A `missing` therefore always means the library omitted something. @@ -901,3 +905,362 @@ function renderJson(value) { } return String(value); } + +// --------------------------------------------------------------------------- +// Rule 8: a byte comparison reports an offset and a window, never a whole file +// --------------------------------------------------------------------------- + +/** + * How much of each side a region shows. Rule 8 caps the window; 80 characters is one terminal line + * and comfortably more than the damage this assertion produces, which is one character wide by + * nature. + */ +export const WINDOW = 80; + +/** + * How many characters must agree before a differing region is considered closed. + * + * Without it, `3.000` come back as `3.0` reports two regions rather than one, because the `0` in + * the middle happens to line up. A region is a place a maintainer looks at, not a character, and + * eight is comfortably longer than any coincidental agreement inside one damaged value while being + * far shorter than the shortest real run of untouched text between two edits. + */ +export const RESYNC = 8; + +/** + * One differing region between two texts, reported as rule 8 requires. + * + * `offset` is into the compared text as the case supplied it, not into the surviving text after + * the excluded extents were removed, so a reader can open the file and go to it. + */ +export class TextRegion { + /** + * @param {{ offset: number, line: number, column: number, rightOffset: number, + * leftText: string, rightText: string }} fields + */ + constructor({ offset, line, column, rightOffset, leftText, rightText }) { + /** @type {number} */ + this.offset = offset; + /** @type {number} */ + this.line = line; + /** @type {number} */ + this.column = column; + /** @type {number} */ + this.rightOffset = rightOffset; + /** @type {string} */ + this.leftText = leftText; + /** @type {string} */ + this.rightText = rightText; + Object.freeze(this); + } + + /** + * One report line: where, and a bounded window of each side. Never a whole file. + * + * @param {{ window?: number }} [options] + * @returns {string} + */ + render({ window = WINDOW } = {}) { + const left = renderWindow(this.leftText, window); + const right = renderWindow(this.rightText, window); + return ( + `differs at offset ${this.offset} (line ${this.line}, column ${this.column}): ` + + `written ${left}, source ${right}` + ); + } +} + +/** + * Every differing region between two texts, in order of offset. + * + * Carries the same three members `Comparison` does, so `AssertionReport` reports a byte comparison + * through exactly the shape it reports every value comparison through. + */ +export class TextComparison { + /** @param {readonly TextRegion[]} [regions] */ + constructor(regions = []) { + /** @type {readonly TextRegion[]} */ + this.regions = Object.freeze([...regions]); + Object.freeze(this); + } + + /** Whether the written text reproduced the source text. */ + get equal() { + return this.regions.length === 0; + } + + /** How many differing regions were found. Regions, not characters (rule 8). */ + get count() { + return this.regions.length; + } + + /** The first differing region, or `null` when the two texts agree. */ + get first() { + return this.regions.length > 0 ? this.regions[0] : null; + } + + /** + * Report lines, at most `limit` of them, with a total count appended when truncated. + * + * @param {{ limit?: number | null, maxValueLength?: number }} [options] + * @returns {readonly string[]} + */ + render({ limit = null, maxValueLength = WINDOW } = {}) { + const shown = limit === null ? this.regions : this.regions.slice(0, limit); + const lines = shown.map((region) => region.render({ window: maxValueLength })); + if (shown.length < this.count) { + lines.push( + `... and ${this.count - shown.length} more differing region(s), ${this.count} in total` + ); + } + return Object.freeze(lines); + } +} + +/** + * Assertion 9: a library's preserving write against that library's own input, byte for byte. + * + * The one textual comparison in this file, permitted by rule 1's bounded exception and reporting + * under rule 8. `left` is what the library wrote and `right` is the text it was given, which keeps + * the orientation every other comparator uses: a difference is always something the library did. + * + * `leftExcluded` and `rightExcluded` are the extents of the objects a case's operations touched, in + * their own side's text. Their contents are never compared, because that text is the library's + * ordinary formatting and legitimately differs between the two libraries, which is the reason rule + * 1 bans textual comparison in the first place. Everything else is compared, and that is the + * property FR-023 states. Both default to empty, which is the read-only case: the whole text is + * compared. + * + * The regions are found by taking the common prefix and the common suffix first, so a single + * insertion or deletion reports as one region bounded by what agrees on either side of it rather + * than as everything after it. A lost trailing newline is therefore one region at the end of the + * file, which is what it is. + * + * @param {string} left + * @param {string} right + * @param {{ leftExcluded?: readonly (readonly [number, number])[], + * rightExcluded?: readonly (readonly [number, number])[] }} [options] + * @returns {TextComparison} + */ +export function comparePreservedText(left, right, { leftExcluded = [], rightExcluded = [] } = {}) { + const [leftKept, leftMap] = surviving(left, leftExcluded); + const [rightKept, rightMap] = surviving(right, rightExcluded); + + const prefix = commonPrefix(leftKept, rightKept); + if (prefix === leftKept.length && prefix === rightKept.length) { + return new TextComparison(); + } + + const suffix = commonSuffix(leftKept, rightKept, prefix); + const leftSpan = leftKept.slice(prefix, leftKept.length - suffix); + const rightSpan = rightKept.slice(prefix, rightKept.length - suffix); + + if (leftSpan.length !== rightSpan.length) { + // The two sides no longer line up, so comparing index by index past this point compares + // unrelated characters. One region, bounded by what still agrees on either side of it. + return new TextComparison([ + buildRegion(left, right, leftMap, rightMap, prefix, leftSpan, rightSpan), + ]); + } + + /** @type {TextRegion[]} */ + const regions = []; + let at = 0; + while (at < leftSpan.length) { + if (leftSpan[at] === rightSpan[at]) { + at += 1; + continue; + } + const start = at; + let agreed = 0; + let end = at; + while (at < leftSpan.length) { + if (leftSpan[at] === rightSpan[at]) { + agreed += 1; + if (agreed >= RESYNC) break; + } else { + agreed = 0; + end = at + 1; + } + at += 1; + } + regions.push( + buildRegion( + left, + right, + leftMap, + rightMap, + prefix + start, + leftSpan.slice(start, end), + rightSpan.slice(start, end) + ) + ); + } + return new TextComparison(regions); +} + +/** + * One region, with its offsets mapped back into the texts the case supplied. + * + * @param {string} left + * @param {string} right + * @param {readonly number[] | null} leftMap + * @param {readonly number[] | null} rightMap + * @param {number} at + * @param {string} leftSpan + * @param {string} rightSpan + * @returns {TextRegion} + */ +function buildRegion(left, right, leftMap, rightMap, at, leftSpan, rightSpan) { + const offset = originalOffset(leftMap, at, left.length); + const rightOffset = originalOffset(rightMap, at, right.length); + const { line, column } = lineColumnAt(left, offset); + return new TextRegion({ offset, line, column, rightOffset, leftText: leftSpan, rightText: rightSpan }); +} + +/** + * The text outside `excluded`, and the original offset each surviving character came from. + * + * The map is what lets a region report an offset into the file rather than into a stitched-up + * string nobody has. It is `null` when nothing was excluded, in which case every surviving offset + * is its own: building the array would cost one number per character of a 600 KB file to answer a + * question the identity answers. + * + * @param {string} text + * @param {readonly (readonly [number, number])[]} excluded + * @returns {[string, readonly number[] | null]} + */ +function surviving(text, excluded) { + if (excluded.length === 0) return [text, null]; + const spans = mergeSpans(excluded, text.length); + /** @type {string[]} */ + const kept = []; + /** @type {number[]} */ + const offsets = []; + let cursor = 0; + for (const [start, end] of spans) { + if (start > cursor) { + kept.push(text.slice(cursor, start)); + for (let at = cursor; at < start; at += 1) offsets.push(at); + } + cursor = Math.max(cursor, end); + } + if (cursor < text.length) { + kept.push(text.slice(cursor)); + for (let at = cursor; at < text.length; at += 1) offsets.push(at); + } + return [kept.join(''), offsets]; +} + +/** + * Clamped, sorted and merged, so overlapping extents cannot drop a character twice. + * + * @param {readonly (readonly [number, number])[]} spans + * @param {number} length + * @returns {[number, number][]} + */ +function mergeSpans(spans, length) { + const clamped = spans + .filter(([start, end]) => end > start) + .map( + ([start, end]) => + /** @type {[number, number]} */ ([ + Math.max(0, Math.min(start, length)), + Math.max(0, Math.min(end, length)), + ]) + ) + .sort((a, b) => a[0] - b[0] || a[1] - b[1]); + /** @type {[number, number][]} */ + const merged = []; + for (const [start, end] of clamped) { + const last = merged[merged.length - 1]; + if (last !== undefined && start <= last[1]) { + last[1] = Math.max(last[1], end); + } else { + merged.push([start, end]); + } + } + return merged; +} + +/** + * Where the surviving character at `at` sat in the original text. + * + * Past the end of the surviving text the answer is the end of the original, which is what a + * difference that is purely a missing tail is about. + * + * @param {readonly number[] | null} offsets + * @param {number} at + * @param {number} length + * @returns {number} + */ +function originalOffset(offsets, at, length) { + if (offsets === null) return Math.min(at, length); + const found = offsets[at]; + return found === undefined ? length : found; +} + +/** + * How many characters the two texts agree on from the start. + * + * @param {string} left + * @param {string} right + * @returns {number} + */ +function commonPrefix(left, right) { + const limit = Math.min(left.length, right.length); + let at = 0; + while (at < limit && left[at] === right[at]) at += 1; + return at; +} + +/** + * How many characters they agree on from the end, without crossing the common prefix. + * + * @param {string} left + * @param {string} right + * @param {number} prefix + * @returns {number} + */ +function commonSuffix(left, right, prefix) { + const limit = Math.min(left.length, right.length) - prefix; + let at = 0; + while (at < limit && left[left.length - 1 - at] === right[right.length - 1 - at]) at += 1; + return at; +} + +/** + * The 1-based line and column an offset falls on. + * + * A line break is a line feed and nothing else, so a carriage return before it belongs to the line + * it ends. That is what every editor reports and what both libraries already count, and a third + * opinion here would put a finding one line away from where a maintainer looks for it. + * + * @param {string} text + * @param {number} offset + * @returns {{ line: number, column: number }} + */ +function lineColumnAt(text, offset) { + const at = Math.max(0, Math.min(offset, text.length)); + const before = text.slice(0, at); + const lineStart = before.lastIndexOf('\n') + 1; + let line = 1; + for (let index = before.indexOf('\n'); index !== -1; index = before.indexOf('\n', index + 1)) { + line += 1; + } + return { line, column: at - lineStart + 1 }; +} + +/** + * A bounded, printable window of one side. Rule 8: never a whole file. + * + * @param {string} span + * @param {number} limit + * @returns {string} + */ +function renderWindow(span, limit) { + if (span === '') return 'nothing'; + const shown = span.slice(0, limit); + const rendered = JSON.stringify(shown); + return shown.length === span.length ? rendered : `${rendered} (+${span.length - shown.length} more)`; +} diff --git a/runners/compare.py b/runners/compare.py index dd328ef..1232992 100644 --- a/runners/compare.py +++ b/runners/compare.py @@ -18,6 +18,10 @@ * Rule 5, strings: :func:`_compare_scalar`, one ``!=``, no normalisation of any kind. * Rule 6, encoding: the runner's, not this module's. Values arrive already decoded. * Rule 7, unordered collections: :func:`compare_unordered`. +* Rule 8, a byte comparison's report: :func:`compare_preserved_text` and :class:`TextRegion`. The + one textual comparison in this module, permitted by rule 1's bounded exception and confined to + the direction that exception names: a library's own output against that library's own input, + never against the other library's output. Orientation, fixed and never swapped: ``left`` is the library under test, ``right`` is the expectation. A ``missing`` therefore always means the library omitted something. @@ -73,9 +77,12 @@ "Difference", "DifferenceKind", "JsonType", + "TextComparison", + "TextRegion", "compare_documents", "compare_epjson", "compare_outcome", + "compare_preserved_text", "compare_unordered", "compare_values", "escape_token", @@ -257,7 +264,7 @@ class AssertionReport: case_id: str library: Library assertion: Assertion - comparison: Comparison + comparison: Comparison | TextComparison @property def passed(self) -> bool: @@ -557,3 +564,260 @@ def _render_value(value: Any, limit: int) -> str: if 0 < limit < len(text): return f"{text[:limit]}... ({len(text)} characters)" return text + + +# --------------------------------------------------------------------------- +# Rule 8: a byte comparison reports an offset and a window, never a whole file +# --------------------------------------------------------------------------- + +# How much of each side a region shows. Rule 8 caps the window; 80 characters is one terminal line +# and comfortably more than the damage this assertion produces, which is one character wide by +# nature. +WINDOW: Final = 80 + +# How many characters must agree before a differing region is considered closed. +# +# Without it, `3.000` come back as `3.0` reports two regions rather than one, because the `0` in +# the middle happens to line up. A region is a place a maintainer looks at, not a character, and +# eight is comfortably longer than any coincidental agreement inside one damaged value while being +# far shorter than the shortest real run of untouched text between two edits. +RESYNC: Final = 8 + + +@dataclass(frozen=True, slots=True) +class TextRegion: + """One differing region between two texts, reported as rule 8 requires. + + ``offset`` is into the compared text as the case supplied it, not into the surviving text after + the excluded extents were removed, so a reader can open the file and go to it. + """ + + offset: int + line: int + column: int + right_offset: int + left_text: str + right_text: str + + def render(self, *, window: int = WINDOW) -> str: + """One report line: where, and a bounded window of each side. Never a whole file.""" + left = _render_window(self.left_text, window) + right = _render_window(self.right_text, window) + return f"differs at offset {self.offset} (line {self.line}, column {self.column}): written {left}, source {right}" + + +@dataclass(frozen=True, slots=True) +class TextComparison: + """Every differing region between two texts, in order of offset. + + Carries the same three members :class:`Comparison` does, so :class:`AssertionReport` reports a + byte comparison through exactly the shape it reports every value comparison through. + """ + + regions: tuple[TextRegion, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "regions", tuple(self.regions)) + + @property + def equal(self) -> bool: + """Whether the written text reproduced the source text.""" + return not self.regions + + @property + def count(self) -> int: + """How many differing regions were found. Regions, not characters (rule 8).""" + return len(self.regions) + + @property + def first(self) -> TextRegion | None: + """The first differing region, or ``None`` when the two texts agree.""" + return self.regions[0] if self.regions else None + + def render(self, *, limit: int | None = None, max_value_length: int = WINDOW) -> tuple[str, ...]: + """Report lines, at most ``limit`` of them, with a total count appended when truncated.""" + shown = self.regions if limit is None else self.regions[:limit] + lines = [region.render(window=max_value_length) for region in shown] + if limit is not None and self.count > len(shown): + lines.append(f"... and {self.count - len(shown)} more differing region(s), {self.count} in total") + return tuple(lines) + + +def compare_preserved_text( + left: str, + right: str, + *, + left_excluded: Sequence[tuple[int, int]] = (), + right_excluded: Sequence[tuple[int, int]] = (), +) -> TextComparison: + """Assertion 9: a library's preserving write against that library's own input, byte for byte. + + The one textual comparison in this file, permitted by rule 1's bounded exception and reporting + under rule 8. ``left`` is what the library wrote and ``right`` is the text it was given, which + keeps the orientation every other comparator uses: a difference is always something the library + did. + + ``left_excluded`` and ``right_excluded`` are the extents of the objects a case's operations + touched, in their own side's text. Their contents are never compared, because that text is the + library's ordinary formatting and legitimately differs between the two libraries, which is the + reason rule 1 bans textual comparison in the first place. Everything else is compared, and that + is the property FR-023 states. Both default to empty, which is the read-only case: the whole + text is compared. + + The regions are found by taking the common prefix and the common suffix first, so a single + insertion or deletion reports as one region bounded by what agrees on either side of it rather + than as everything after it. A lost trailing newline is therefore one region at the end of the + file, which is what it is. + """ + left_kept, left_map = _surviving(left, left_excluded) + right_kept, right_map = _surviving(right, right_excluded) + + prefix = _common_prefix(left_kept, right_kept) + if prefix == len(left_kept) == len(right_kept): + return TextComparison() + + suffix = _common_suffix(left_kept, right_kept, prefix) + left_span = left_kept[prefix : len(left_kept) - suffix] + right_span = right_kept[prefix : len(right_kept) - suffix] + + if len(left_span) != len(right_span): + # The two sides no longer line up, so comparing index by index past this point compares + # unrelated characters. One region, bounded by what still agrees on either side of it. + return TextComparison((_region(left, right, left_map, right_map, prefix, left_span, right_span),)) + + regions: list[TextRegion] = [] + at = 0 + while at < len(left_span): + if left_span[at] == right_span[at]: + at += 1 + continue + start = at + agreed = 0 + end = at + while at < len(left_span): + if left_span[at] == right_span[at]: + agreed += 1 + if agreed >= RESYNC: + break + else: + agreed = 0 + end = at + 1 + at += 1 + regions.append( + _region(left, right, left_map, right_map, prefix + start, left_span[start:end], right_span[start:end]) + ) + return TextComparison(tuple(regions)) + + +def _region( + left: str, + right: str, + left_map: Sequence[int] | None, + right_map: Sequence[int] | None, + at: int, + left_span: str, + right_span: str, +) -> TextRegion: + """One region, with its offsets mapped back into the texts the case supplied.""" + left_offset = _original_offset(left_map, at, len(left)) + right_offset = _original_offset(right_map, at, len(right)) + line, column = _line_column_at(left, left_offset) + return TextRegion( + offset=left_offset, + line=line, + column=column, + right_offset=right_offset, + left_text=left_span, + right_text=right_span, + ) + + +def _surviving(text: str, excluded: Sequence[tuple[int, int]]) -> tuple[str, tuple[int, ...] | None]: + """The text outside ``excluded``, and the original offset each surviving character came from. + + The map is what lets a region report an offset into the file rather than into a stitched-up + string nobody has. It is ``None`` when nothing was excluded, in which case every surviving + offset is its own: building the tuple would cost one integer per character of a 600 KB file to + answer a question the identity answers. + """ + if not excluded: + return text, None + spans = _merge_spans(excluded, len(text)) + kept: list[str] = [] + offsets: list[int] = [] + cursor = 0 + for start, end in spans: + if start > cursor: + kept.append(text[cursor:start]) + offsets.extend(range(cursor, start)) + cursor = max(cursor, end) + if cursor < len(text): + kept.append(text[cursor:]) + offsets.extend(range(cursor, len(text))) + return "".join(kept), tuple(offsets) + + +def _merge_spans(spans: Sequence[tuple[int, int]], length: int) -> tuple[tuple[int, int], ...]: + """Clamped, sorted and merged, so overlapping extents cannot drop a character twice.""" + clamped = sorted( + (max(0, min(start, length)), max(0, min(end, length))) for start, end in spans if end > start + ) + merged: list[tuple[int, int]] = [] + for start, end in clamped: + if merged and start <= merged[-1][1]: + merged[-1] = (merged[-1][0], max(merged[-1][1], end)) + else: + merged.append((start, end)) + return tuple(merged) + + +def _original_offset(offsets: Sequence[int] | None, at: int, length: int) -> int: + """Where the surviving character at ``at`` sat in the original text. + + Past the end of the surviving text the answer is the end of the original, which is what a + difference that is purely a missing tail is about. + """ + if offsets is None: + return min(at, length) + if at < len(offsets): + return offsets[at] + return length + + +def _common_prefix(left: str, right: str) -> int: + """How many characters the two texts agree on from the start.""" + limit = min(len(left), len(right)) + at = 0 + while at < limit and left[at] == right[at]: + at += 1 + return at + + +def _common_suffix(left: str, right: str, prefix: int) -> int: + """How many characters they agree on from the end, without crossing the common prefix.""" + limit = min(len(left), len(right)) - prefix + at = 0 + while at < limit and left[len(left) - 1 - at] == right[len(right) - 1 - at]: + at += 1 + return at + + +def _line_column_at(text: str, offset: int) -> tuple[int, int]: + """The 1-based line and column an offset falls on. + + A line break is a line feed and nothing else, so a carriage return before it belongs to the line + it ends. That is what every editor reports and what both libraries already count, and a third + opinion here would put a finding one line away from where a maintainer looks for it. + """ + at = max(0, min(offset, len(text))) + line_start = text.rfind("\n", 0, at) + 1 + return text.count("\n", 0, at) + 1, at - line_start + 1 + + +def _render_window(span: str, limit: int) -> str: + """A bounded, printable window of one side. Rule 8: never a whole file.""" + if span == "": + return "nothing" + shown = span[:limit] + rendered = json.dumps(shown) + return rendered if len(shown) == len(span) else f"{rendered} (+{len(span) - len(shown)} more)" diff --git a/runners/model.mjs b/runners/model.mjs index 729474a..9c76a07 100644 --- a/runners/model.mjs +++ b/runners/model.mjs @@ -102,6 +102,7 @@ export const Assertion = Object.freeze({ INTROSPECTION: 'introspection', DOCS_URL: 'docs-url', TYPE_LOOKUP: 'type-lookup', + PRESERVED_TEXT: 'preserved-text', }); /** @@ -212,6 +213,23 @@ export const Library = Object.freeze({ TYPESCRIPT: 'typescript', }); +/** + * The four changes a case may declare between reading and writing. + * + * Four, and no more: they are the four the touched record has to get right, which is what the + * `preserved-text` assertion narrows to when a case declares them. A fifth is added when a claim + * needs it, not in advance. + * + * @readonly + * @enum {string} + */ +export const OperationKind = Object.freeze({ + SET_FIELD: 'set-field', + RENAME: 'rename', + REMOVE: 'remove', + ADD: 'add', +}); + // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- @@ -333,6 +351,81 @@ export class Case { * `additionalProperties: false` in the schema rejects it, and duplicating it on disk would let the * two disagree. */ +export class Operation { + /** + * One change a case applies to its document between the read and the write. + * + * Language-neutral, exactly as `WriterOptions` is: `type` and `name` identify the object, and + * each runner maps the four kinds onto its own library's mutators. Parsed into this rather than + * left as a bare object so that a malformed operation is a load error naming the case, rather + * than a property access failing inside a run where it reads as a library failure. + * + * `value` and the members of `fields` are text, because a case says what a file would have said + * and each runner coerces it the way its own reader coerces a value read from a file. + * + * @param {{ op: OperationKind, type: string, name: string, field?: string | null, + * value?: string | null, to?: string | null, + * fields?: Readonly> | null }} fields + */ + constructor({ op, type, name, field = null, value = null, to = null, fields = null }) { + /** @type {OperationKind} */ + this.op = op; + /** @type {string} */ + this.type = type; + /** @type {string} */ + this.name = name; + /** @type {string | null} */ + this.field = field ?? null; + /** @type {string | null} */ + this.value = value ?? null; + /** @type {string | null} */ + this.to = to ?? null; + /** @type {Readonly> | null} */ + this.fields = fields === null ? null : Object.freeze({ ...fields }); + + checkNonEmpty(this.type, 'type', ManifestError); + + if (this.op === OperationKind.SET_FIELD) { + if (this.field === null || this.field.trim() === '') { + throw new ManifestError("operation 'set-field' requires a non-empty 'field'"); + } + if (this.value === null) { + throw new ManifestError("operation 'set-field' requires a 'value'"); + } + } else if (this.field !== null || this.value !== null) { + throw new ManifestError(`operation ${repr(this.op)} takes neither 'field' nor 'value'`); + } + + if (this.op === OperationKind.RENAME) { + if (this.to === null || this.to.trim() === '') { + throw new ManifestError("operation 'rename' requires a non-empty 'to'"); + } + } else if (this.to !== null) { + throw new ManifestError(`operation ${repr(this.op)} takes no 'to'`); + } + + if (this.op !== OperationKind.ADD && this.fields !== null) { + throw new ManifestError(`operation ${repr(this.op)} takes no 'fields'`); + } + Object.freeze(this); + } + + /** + * The operation as it is written to `manifest.json`. The JSON boundary, not a model type. + * + * @returns {Record} + */ + toJsonObj() { + /** @type {Record} */ + const entry = { op: this.op, type: this.type, name: this.name }; + if (this.field !== null) entry.field = this.field; + if (this.value !== null) entry.value = this.value; + if (this.to !== null) entry.to = this.to; + if (this.fields !== null) entry.fields = { ...this.fields }; + return entry; + } +} + export class WriterOptions { /** * Writer controls a case applies before re-reading its own output. @@ -412,6 +505,7 @@ export class ManifestEntry { expectedDocsUrl = null, expectedTypeLookup = null, writerOptions = null, + operations = [], }) { /** @type {string} */ this.id = id; @@ -443,6 +537,8 @@ export class ManifestEntry { this.expectedTypeLookup = expectedTypeLookup ?? null; /** @type {WriterOptions | null} */ this.writerOptions = writerOptions ?? null; + /** @type {readonly Operation[]} */ + this.operations = Object.freeze([...(operations ?? [])]); checkCaseId(this.id, ManifestError); checkNonEmpty(this.title, 'title', ManifestError); @@ -512,6 +608,9 @@ export class ManifestEntry { entry[key] = named; } } + if (this.operations.length > 0) { + entry.operations = this.operations.map((operation) => operation.toJsonObj()); + } return entry; } } @@ -1499,6 +1598,7 @@ const ENTRY_KEYS = new Set([ 'expected_docs_url', 'expected_type_lookup', 'writer_options', + 'operations', ]); const MANIFEST_KEYS = new Set([ '$schema', @@ -1596,6 +1696,88 @@ function loadEntry(rawEntry, truth, path, index) { expectedDocsUrl: readOptionalString(raw, 'expected_docs_url', options), expectedTypeLookup: readOptionalString(raw, 'expected_type_lookup', options), writerOptions: readWriterOptions(raw, options), + operations: readOperations(raw, options), + }); +} + +const OPERATION_KEYS = new Set(['op', 'type', 'name', 'field', 'value', 'to', 'fields']); + +/** + * Read the optional `operations` list, or an empty array when the case declares none. + * + * An absent block means read only, which is what every case written before this assertion means + * and must keep meaning. A block that is present and empty is rejected rather than treated as + * absent, because writing one is a mistake rather than a way of saying nothing. + * + * @param {Record} raw + * @param {{ path: string, where: string, error: * }} options + * @returns {Operation[]} + */ +function readOperations(raw, options) { + const value = raw['operations']; + if (value === undefined || value === null) return []; + const where = `${options.where}.operations`; + if (!Array.isArray(value)) { + throw new ManifestError( + `${options.path}: ${where}: expected a list, got ${typeof value}` + ); + } + if (value.length === 0) { + throw new ManifestError( + `${options.path}: ${where}: is empty; omit the block to mean read only` + ); + } + + return value.map((rawOperation, index) => { + const at = `${where}[${index}]`; + const inner = { ...options, where: at }; + const block = readMapping(rawOperation, options.path, at, ManifestError); + rejectUnknownKeys(block, OPERATION_KEYS, inner); + + const rawFields = block['fields']; + /** @type {Record | null} */ + let fields = null; + if (rawFields !== undefined && rawFields !== null) { + const table = readMapping(rawFields, options.path, `${at}.fields`, ManifestError); + fields = {}; + for (const [fieldName, fieldValue] of Object.entries(table)) { + if (typeof fieldValue !== 'string') { + throw new ManifestError( + `${options.path}: ${at}.fields.${fieldName}: must be a string, because a case says ` + + `what a file would have said, got ${repr(fieldValue)}` + ); + } + fields[fieldName] = fieldValue; + } + } + + // `name` may legitimately be blank, for a type that declares an optional name, so it is read + // as a plain string rather than through the non-empty helper. + const name = block['name']; + if (typeof name !== 'string') { + throw new ManifestError( + `${options.path}: ${at}: 'name' must be a string, got ${repr(name)}` + ); + } + + const rawValue = block['value']; + try { + return new Operation({ + op: readEnum(block, 'op', OperationKind, inner), + type: readString(block, 'type', inner), + name, + field: readOptionalString(block, 'field', inner), + value: typeof rawValue === 'string' ? rawValue : null, + to: readOptionalString(block, 'to', inner), + fields, + }); + } catch (error) { + // Re-thrown with the file and the case position, so a malformed operation names the case + // rather than surfacing as a bare rule violation with no address. + throw new ManifestError( + `${options.path}: ${at}: ${error instanceof Error ? error.message : String(error)}` + ); + } }); } diff --git a/runners/model.py b/runners/model.py index e840bdd..f1d25fa 100644 --- a/runners/model.py +++ b/runners/model.py @@ -55,6 +55,8 @@ "Manifest", "ManifestEntry", "ManifestError", + "Operation", + "OperationKind", "ParseOutcome", "Tag", "Truth", @@ -122,6 +124,12 @@ class Assertion(str, Enum): when a *caller* names an object type, which is the question the two libraries answered differently for as long as nobody wrote it down: ``d["zone"]`` was empty in Python and six zones in TypeScript on the same parsed document. + + ``PRESERVED_TEXT`` is the one byte-level assertion, and it is not ``ROUND_TRIP`` under another + name. Round-trip re-reads a library's own output and compares documents, saying nothing about + the bytes in between, and it writes with preservation explicitly off because otherwise the + lossless path would echo the source back and make the assertion trivially true. This assertion + is that echo, asserted deliberately, and the two must never be merged. """ PARSE_OUTCOME = "parse-outcome" @@ -132,6 +140,7 @@ class Assertion(str, Enum): INTROSPECTION = "introspection" DOCS_URL = "docs-url" TYPE_LOOKUP = "type-lookup" + PRESERVED_TEXT = "preserved-text" class Tag(str, Enum): @@ -188,6 +197,20 @@ class Library(str, Enum): TYPESCRIPT = "typescript" +class OperationKind(str, Enum): + """The four changes a case may declare between reading and writing. + + Four, and no more: they are the four the touched record has to get right, which is what the + ``preserved-text`` assertion narrows to when a case declares them. A fifth is added when a + claim needs it, not in advance. + """ + + SET_FIELD = "set-field" + RENAME = "rename" + REMOVE = "remove" + ADD = "add" + + # --------------------------------------------------------------------------- # Errors # --------------------------------------------------------------------------- @@ -245,6 +268,62 @@ def expects_diagnostics(self) -> bool: return Assertion.DIAGNOSTICS in self.assertions +@dataclass(frozen=True, slots=True) +class Operation: + """One change a case applies to its document between the read and the write. + + Language-neutral, exactly as :class:`WriterOptions` is: ``type`` and ``name`` identify the + object, and each runner maps the four kinds onto its own library's mutators. Parsed into this + rather than left as a bare mapping so that a malformed operation is a load error naming the + case, rather than an attribute error inside a run where it reads as a library failure. + + ``value`` and the members of ``fields`` are text, because a case says what a file would have + said and each runner coerces it the way its own reader coerces a value read from a file. + """ + + op: OperationKind + type: str + name: str + field: str | None = None + value: str | None = None + to: str | None = None + fields: Mapping[str, str] | None = None + + def __post_init__(self) -> None: + _check_non_empty(self.type, "type", ManifestError) + if self.op is OperationKind.SET_FIELD: + if not (self.field or "").strip(): + raise ManifestError("operation 'set-field' requires a non-empty 'field'") + if self.value is None: + raise ManifestError("operation 'set-field' requires a 'value'") + elif self.field is not None or self.value is not None: + raise ManifestError(f"operation {self.op.value!r} takes neither 'field' nor 'value'") + + if self.op is OperationKind.RENAME: + if not (self.to or "").strip(): + raise ManifestError("operation 'rename' requires a non-empty 'to'") + elif self.to is not None: + raise ManifestError(f"operation {self.op.value!r} takes no 'to'") + + if self.op is not OperationKind.ADD and self.fields is not None: + raise ManifestError(f"operation {self.op.value!r} takes no 'fields'") + if self.fields is not None: + object.__setattr__(self, "fields", dict(self.fields)) + + def to_json_obj(self) -> dict[str, Any]: + """The operation as it is written to ``manifest.json``. The JSON boundary, not a model type.""" + entry: dict[str, Any] = {"op": self.op.value, "type": self.type, "name": self.name} + if self.field is not None: + entry["field"] = self.field + if self.value is not None: + entry["value"] = self.value + if self.to is not None: + entry["to"] = self.to + if self.fields is not None: + entry["fields"] = dict(self.fields) + return entry + + @dataclass(frozen=True, slots=True) class WriterOptions: """Writer controls a case applies before re-reading its own output. @@ -304,10 +383,12 @@ class ManifestEntry: expected_docs_url: str | None = None expected_type_lookup: str | None = None writer_options: WriterOptions | None = None + operations: tuple[Operation, ...] = () def __post_init__(self) -> None: object.__setattr__(self, "tags", tuple(self.tags)) object.__setattr__(self, "assertions", tuple(self.assertions)) + object.__setattr__(self, "operations", tuple(self.operations)) _check_case_id(self.id, ManifestError) _check_non_empty(self.title, "title", ManifestError) _check_unique_non_empty(self.tags, "tags", ManifestError) @@ -357,6 +438,8 @@ def to_json_obj(self) -> dict[str, Any]: named: str | None = getattr(self, key) if named is not None: entry[key] = named + if self.operations: + entry["operations"] = [operation.to_json_obj() for operation in self.operations] return entry @@ -602,6 +685,69 @@ def _count(key: str) -> int | None: ) +_OPERATION_KEYS: Final = frozenset({"op", "type", "name", "field", "value", "to", "fields"}) + + +def _read_operations(raw: Mapping[str, Any], *, path: Path, where: str) -> tuple[Operation, ...]: + """Read the optional ``operations`` list, or an empty tuple when the case declares none. + + An absent block means read only, which is what every case written before this assertion means + and must keep meaning. A block that is present and empty is rejected rather than treated as + absent, because writing one is a mistake rather than a way of saying nothing. + """ + value = raw.get("operations") + if value is None: + return () + if not isinstance(value, list): + _fail(ManifestError, path, f"{where}.operations", f"expected a list, got {type(value).__name__}") + if not value: + _fail(ManifestError, path, f"{where}.operations", "is empty; omit the block to mean read only") + + operations: list[Operation] = [] + for index, raw_operation in enumerate(value): + at = f"{where}.operations[{index}]" + block = _read_mapping(raw_operation, path, at, ManifestError) + _reject_unknown_keys(block, _OPERATION_KEYS, path=path, where=at, error=ManifestError) + + fields_value = block.get("fields") + fields: dict[str, str] | None = None + if fields_value is not None: + table = _read_mapping(fields_value, path, f"{at}.fields", ManifestError) + for field_name, field_value in table.items(): + if not isinstance(field_value, str): + _fail( + ManifestError, + path, + f"{at}.fields.{field_name}", + f"must be a string, because a case says what a file would have said, got {field_value!r}", + ) + fields = {str(key): str(item) for key, item in table.items()} + + # `name` may legitimately be blank, for a type that declares an optional name, so it is + # read as a plain string rather than through the non-empty helper. + name = block.get("name") + if not isinstance(name, str): + _fail(ManifestError, path, at, f"'name' must be a string, got {name!r}") + + try: + operations.append( + Operation( + op=_read_enum(block, "op", OperationKind, path=path, where=at, error=ManifestError), + type=_read_str(block, "type", path=path, where=at, error=ManifestError), + name=name, + field=_read_optional_str(block, "field", path=path, where=at, error=ManifestError), + value=block.get("value") if isinstance(block.get("value"), str) else None, + to=_read_optional_str(block, "to", path=path, where=at, error=ManifestError), + fields=fields, + ) + ) + except ManifestError as error: + # Re-raised with the file and the case position, so a malformed operation names the + # case rather than surfacing as a bare rule violation with no address. + _fail(ManifestError, path, at, str(error)) + return tuple(operations) + + def _read_optional_str( raw: Mapping[str, Any], key: str, *, path: Path, where: str, error: type[CorpusError] ) -> str | None: @@ -682,6 +828,7 @@ def _reject_unknown_keys( "expected_docs_url", "expected_type_lookup", "writer_options", + "operations", } ) _MANIFEST_KEYS: Final = frozenset({"$schema", "schema_version", "corpus_level", "oracle", "convention"}) @@ -756,6 +903,7 @@ def _load_entry(raw_entry: object, truth: Truth, path: Path, index: int) -> Mani raw, "expected_type_lookup", path=path, where=where, error=ManifestError ), writer_options=_read_writer_options(raw, path=path, where=where), + operations=_read_operations(raw, path=path, where=where), ) diff --git a/runners/run.mjs b/runners/run.mjs index 8f5fd16..c4128f1 100644 --- a/runners/run.mjs +++ b/runners/run.mjs @@ -77,7 +77,7 @@ */ import { spawnSync } from 'node:child_process'; -import { mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { availableParallelism, tmpdir } from 'node:os'; import { gunzipSync } from 'node:zlib'; import { dirname, isAbsolute, join, resolve, sep } from 'node:path'; @@ -89,6 +89,7 @@ import { compareDocuments, compareEpjson, compareOutcome, + comparePreservedText, compareUnordered, compareValues, Comparison, @@ -108,6 +109,7 @@ import { InputFile, Library, MANIFEST_FILE, + OperationKind, ParseOutcome, Tag, Truth, @@ -138,6 +140,7 @@ const ASSERTION_ORDER = Object.freeze([ Assertion.INTROSPECTION, Assertion.DOCS_URL, Assertion.TYPE_LOOKUP, + Assertion.PRESERVED_TEXT, ]); const DEFAULT_DIFFERENCE_LIMIT = 20; @@ -251,7 +254,8 @@ export class CaseJob { /** * @param {{ caseId: string, caseDir: string, inputFile: InputFile, * expectedParseOutcome: ParseOutcome, truth: Truth, - * assertions: readonly Assertion[], writerOptions?: * }} fields + * assertions: readonly Assertion[], writerOptions?: *, + * operations?: readonly * [] }} fields */ constructor({ caseId, @@ -261,6 +265,7 @@ export class CaseJob { truth, assertions, writerOptions = null, + operations = [], }) { /** @type {string} */ this.caseId = caseId; @@ -280,6 +285,12 @@ export class CaseJob { * @type {* | null} */ this.writerOptions = writerOptions ?? null; + /** + * Changes to apply between reading and writing, or empty for a read-only case. + * + * @type {readonly *[]} + */ + this.operations = Object.freeze([...operations]); Object.freeze(this); } @@ -691,6 +702,7 @@ export function buildJobs(corpus, caseIds, tags) { truth: entry.truth, assertions: ASSERTION_ORDER.filter((assertion) => entry.assertions.includes(assertion)), writerOptions: entry.writerOptions, + operations: entry.operations, }) ); } @@ -967,6 +979,9 @@ async function runAssertion(library, job, assertion, parse, limit) { if (assertion === Assertion.TYPE_LOOKUP) { return assertTypeLookup(library, job, parse.document, limit); } + if (assertion === Assertion.PRESERVED_TEXT) { + return await assertPreservedText(library, job, limit); + } // A ninth assertion added to model.mjs without a runner change lands here. Saying so beats // falling through to whichever branch happened to be last. return errored( @@ -1062,6 +1077,307 @@ function noDocument(job, assertion, parse) { * @param {number} limit * @returns {AssertionOutcome} */ +/** + * Assertion 9: the library's preserving write of a text against that same text, byte for byte. + * + * Declaring this assertion asserts the property over EVERY source text the case supplies (FR-024), + * which is the case input always and, for an oracle case, its committed `expected.epJSON` read as + * an object-notation input as well. Those 37 files carry a third tool's formatting rather than + * either library's, which is exactly the kind of sample this claim needs, and reading them here + * does not change what they mean as the expectation for assertion 2. + * + * This assertion does its own read, deliberately. Every other assertion shares the run's one parse, + * which is a plain read; this one is about what a PRESERVING read retains, so a shared plain read + * would test nothing. + * + * @param {LibraryUnderTest} library + * @param {CaseJob} job + * @param {number} limit + * @returns {Promise} + */ +async function assertPreservedText(library, job, limit) { + /** @type {[string, string][]} */ + const texts = [['input', job.inputPath]]; + if (job.truth === Truth.ORACLE && existsSync(job.expectedEpjsonPath)) { + texts.push(['expected.epJSON', job.expectedEpjsonPath]); + } + + for (const [label, path] of texts) { + const outcome = await preservedTextOf(library, job, label, path, limit); + if (outcome.status !== Status.PASSED) return outcome; + } + return new AssertionOutcome({ + caseId: job.caseId, + assertion: Assertion.PRESERVED_TEXT, + status: Status.PASSED, + }); +} + +/** + * One text: read it preserving, apply the case's operations, write, compare bytes. + * + * @param {LibraryUnderTest} library + * @param {CaseJob} job + * @param {string} label + * @param {string} path + * @param {number} limit + * @returns {Promise} + */ +async function preservedTextOf(library, job, label, path, limit) { + let preserved; + try { + preserved = await preservingRoundTrip(library, job, path); + } catch (error) { + if (error instanceof ExtentUnknown) { + // Never a pass. A runner that cannot say where a touched object sits cannot say which bytes + // the assertion excludes, so it has no verdict to give. + return errored(job.caseId, Assertion.PRESERVED_TEXT, `${label}: ${error.message}`); + } + return errored( + job.caseId, + Assertion.PRESERVED_TEXT, + `${label}: the preserving round trip raised ${describeError(error)}` + ); + } + + const comparison = comparePreservedText(preserved.written, preserved.source, { + leftExcluded: preserved.writtenExcluded, + rightExcluded: preserved.sourceExcluded, + }); + return fromComparison(job.caseId, Assertion.PRESERVED_TEXT, comparison, limit, [ + `comparing the write of ${label} against ${label} itself`, + ]); +} + +/** The runner could not place a touched object's text on one side or the other. */ +class ExtentUnknown extends RunnerError {} + +/** + * Read `path` with preservation, apply the case's operations, and write it back. + * + * The object notation preserves on all-or-nothing terms in both languages, so a case declaring + * operations over one has no per-object extents to exclude and no meaningful narrowing. The corpus + * has no such case, and one appearing is a corpus fault rather than something to guess at. + * + * @param {LibraryUnderTest} library + * @param {CaseJob} job + * @param {string} path + * @returns {Promise<{ source: string, written: string, + * sourceExcluded: readonly [number, number][], + * writtenExcluded: readonly [number, number][] }>} + */ +async function preservingRoundTrip(library, job, path) { + // Read as bytes and decode here. Node's text reading translates nothing, unlike Python's, but + // the decode is spelled out anyway so the three line-ending cases are compared against the file + // the case actually contains rather than against a normalised copy of it. + const isIdf = path.endsWith('.idf'); + const source = readFileSync(path).toString(isIdf ? IDF_ENCODING : 'utf8'); + + if (!isIdf) { + if (job.operations.length > 0) { + throw new ExtentUnknown( + 'the case declares operations over an object-notation text, which preserves on ' + + 'all-or-nothing terms and has no per-object extent to exclude' + ); + } + const schema = await library.node.schemaFor(library.core.getEpJsonVersion(source)); + const { document } = library.core.parseEpJson(source, schema, { preserveFormatting: true }); + return { + source, + written: library.core.writeEpJson(document), + sourceExcluded: [], + writtenExcluded: [], + }; + } + + const schema = await library.node.schemaFor(library.core.getIdfVersion(source)); + const { document } = library.core.parseIdf(source, schema, { preserveFormatting: true }); + const sourceSpans = statementSpans(library, source); + applyOperations(library, document, job.operations); + const written = library.core.writeIdf(document); + if (job.operations.length === 0) { + return { source, written, sourceExcluded: [], writtenExcluded: [] }; + } + + // The extents come from the library's own syntax layer, on both sides: the statement a touched + // object was read from, and the statement it was written as. Matching them by position is sound + // for the same reason the writer's own anchoring is, that both walks visit statements in source + // order, and it stops being sound the moment a statement is added or removed. So the two walks + // are reconciled on the text that did NOT change rather than on an index. + const writtenSpans = statementSpans(library, written); + return reconcileSpans(job, source, written, sourceSpans, writtenSpans); +} + +/** + * Every statement's extent in one text, from the library's own scan. + * + * @param {LibraryUnderTest} library + * @param {string} text + * @returns {[number, number][]} + */ +function statementSpans(library, text) { + const layer = library.core.scanIdf(text); + return layer.statements.map((statement) => [statement.region.start, statement.region.end]); +} + +/** + * Which statements differ between the two sides, as extents to exclude on each. + * + * The two texts agree everywhere the write preserved and differ inside the statements it did not. + * So the extents are found by walking the two statement lists together and taking the longest run + * that matches character for character from each end: what is left in the middle on each side is + * the text of the objects the operations touched, whether that is one statement reformatted, a + * statement gone, or a statement appended. + * + * This determines an extent from the layer, as the contract requires, and it needs no map from a + * statement to the object it produced: the assertion excludes text, not objects. + * + * @param {CaseJob} job + * @param {string} source + * @param {string} written + * @param {[number, number][]} sourceSpans + * @param {[number, number][]} writtenSpans + */ +function reconcileSpans(job, source, written, sourceSpans, writtenSpans) { + let head = 0; + while ( + head < sourceSpans.length && + head < writtenSpans.length && + sliceOf(source, sourceSpans[head]) === sliceOf(written, writtenSpans[head]) && + gapBefore(source, sourceSpans, head) === gapBefore(written, writtenSpans, head) + ) { + head += 1; + } + + let tail = 0; + while ( + tail < sourceSpans.length - head && + tail < writtenSpans.length - head && + sliceOf(source, sourceSpans[sourceSpans.length - 1 - tail]) === + sliceOf(written, writtenSpans[writtenSpans.length - 1 - tail]) + ) { + tail += 1; + } + + const sourceMiddle = sourceSpans.slice(head, sourceSpans.length - tail); + const writtenMiddle = writtenSpans.slice(head, writtenSpans.length - tail); + if (sourceMiddle.length === 0 && writtenMiddle.length === 0 && job.operations.length > 0) { + throw new ExtentUnknown( + 'the case declares operations and the written text reproduces every statement of the ' + + 'source, so no statement can be identified as the one that changed' + ); + } + + // One span per side, from the first differing statement to the last, plus the separator that + // follows it: the writer's own punctuation around a reformatted object is written for that + // object and has no counterpart in the source. + return { + source, + written, + sourceExcluded: sourceMiddle.length === 0 ? [] : [enclosing(source, sourceMiddle)], + writtenExcluded: writtenMiddle.length === 0 ? [] : [enclosing(written, writtenMiddle)], + }; +} + +/** + * @param {string} text + * @param {[number, number]} span + * @returns {string} + */ +function sliceOf(text, span) { + return text.slice(span[0], span[1]); +} + +/** + * The text between the previous statement and this one, which belongs to no object. + * + * @param {string} text + * @param {[number, number][]} spans + * @param {number} index + * @returns {string} + */ +function gapBefore(text, spans, index) { + const from = index === 0 ? 0 : spans[index - 1][1]; + return text.slice(from, spans[index][0]); +} + +/** + * One span covering every statement in `spans`, and the blank text that trails the last of them. + * + * @param {string} text + * @param {[number, number][]} spans + * @returns {[number, number]} + */ +function enclosing(text, spans) { + const start = spans[0][0]; + let end = spans[spans.length - 1][1]; + while (end < text.length && (text[end] === '\n' || text[end] === '\r')) end += 1; + return [start, end]; +} + +/** + * Apply a case's declared changes, in list order, through the library's own mutators. + * + * A case says what a FILE would have said, so a value arrives as text and is coerced the way this + * library's own reader coerces a value read from a file. Handing the text straight over instead + * would make every `set-field` on a numeric field a change even when the case wrote the value + * already held, which is the one property `preserve-edit-no-op` exists to check. + * + * @param {LibraryUnderTest} library + * @param {*} document + * @param {readonly *[]} operations + */ +function applyOperations(library, document, operations) { + for (const operation of operations) { + if (operation.op === OperationKind.ADD) { + /** @type {Record} */ + const fields = {}; + for (const [field, value] of Object.entries(operation.fields ?? {})) { + fields[field] = coerced(document, operation.type, field, value); + } + document.add(operation.type, operation.name, fields); + continue; + } + + const object = document.get(operation.type, operation.name); + if (object === undefined) { + throw new ExtentUnknown( + `operation ${quote(operation.op)} names ${operation.type} ${quote(operation.name)}, ` + + 'which the document does not hold' + ); + } + if (operation.op === OperationKind.SET_FIELD) { + object.set(operation.field, coerced(document, operation.type, operation.field, operation.value)); + } else if (operation.op === OperationKind.RENAME) { + object.name = operation.to; + } else { + document.remove(object); + } + } +} + +/** + * A case's text turned into the value this library's own reader would have made of it. + * + * The schema decides, not the text: a numeric-looking string in a string field stays a string, and + * a sizing sentinel in a numeric field stays a string too, because that is what the reader does + * with both. + * + * @param {*} document + * @param {string} typeName + * @param {string} field + * @param {string} value + * @returns {string | number} + */ +function coerced(document, typeName, field, value) { + const description = document.schema.require(typeName)?.p?.[field]; + const kind = description?.t; + if (kind !== 'n' && kind !== 'i') return value; + const number = Number(value); + if (Number.isNaN(number) || value.trim() === '') return value; + return kind === 'i' ? Math.trunc(number) : number; +} + function assertParseOutcome(job, parse, limit) { const comparison = compareOutcome(parse.outcome, job.expectedParseOutcome); const extra = parse.error ? [`parse error: ${parse.error}`] : []; diff --git a/runners/run.py b/runners/run.py index 834ae54..99935f7 100644 --- a/runners/run.py +++ b/runners/run.py @@ -94,9 +94,11 @@ from compare import ( # noqa: E402 AssertionReport, Comparison, + TextComparison, compare_documents, compare_epjson, compare_outcome, + compare_preserved_text, compare_unordered, compare_values, json_pointer, @@ -119,6 +121,8 @@ InputFile, Library, Manifest, + Operation, + OperationKind, ParseOutcome, Tag, Truth, @@ -162,6 +166,7 @@ Assertion.INTROSPECTION, Assertion.DOCS_URL, Assertion.TYPE_LOOKUP, + Assertion.PRESERVED_TEXT, ) # The members of one field description, in the order ``compare.md`` writes them. Spelled out here @@ -262,6 +267,7 @@ class CaseJob: truth: Truth assertions: tuple[Assertion, ...] writer_options: WriterOptions | None = None + operations: tuple[Operation, ...] = () @property def input_path(self) -> Path: @@ -499,6 +505,7 @@ def build_jobs(corpus: Corpus, case_ids: Sequence[str], tags: Sequence[Tag]) -> truth=entry.truth, assertions=tuple(a for a in ASSERTION_ORDER if a in entry.assertions), writer_options=entry.writer_options, + operations=entry.operations, ) ) @@ -674,6 +681,8 @@ def _run_assertion(job: CaseJob, assertion: Assertion, parse: _Parse, limit: int return _assert_docs_url(job, parse.document, limit) if assertion is Assertion.TYPE_LOOKUP: return _assert_type_lookup(job, parse.document, limit) + if assertion is Assertion.PRESERVED_TEXT: + return _assert_preserved_text(job, limit) # A further assertion added to model.py without a runner change lands here. Saying so beats # falling through to whichever branch happened to be last. return _errored(job.case_id, assertion, f"this runner has no implementation for {assertion.value!r}") @@ -763,6 +772,264 @@ def _assert_round_trip(job: CaseJob, document: Any, limit: int) -> AssertionOutc return _from_comparison(job.case_id, Assertion.ROUND_TRIP, compare_documents(reparsed, original), limit) +@dataclass(frozen=True) +class _Preserved: + """One preserving round trip: the text handed over, the text written back, and the extents. + + The extents are the objects a case's operations touched, on their own side. They are empty for + a read-only case, which is every case that declares no operations, and is 46 of the 50 carrying + this assertion. + """ + + source: str + written: str + source_excluded: tuple[tuple[int, int], ...] = () + written_excluded: tuple[tuple[int, int], ...] = () + + +def _assert_preserved_text(job: CaseJob, limit: int) -> AssertionOutcome: + """Assertion 9: the library's preserving write of a text against that same text, byte for byte. + + Declaring this assertion asserts the property over EVERY source text the case supplies (FR-024), + which is the case input always and, for an oracle case, its committed ``expected.epJSON`` read + as an object-notation input as well. Those 37 files carry a third tool's formatting rather than + either library's, which is exactly the kind of sample this claim needs, and reading them here + does not change what they mean as the expectation for assertion 2. + + This assertion does its own read, deliberately. Every other assertion shares the run's one parse, + which is a plain read; this one is about what a PRESERVING read retains, so a shared plain read + would test nothing. + """ + texts: list[tuple[str, Path]] = [("input", job.input_path)] + if job.truth is Truth.ORACLE and job.expected_epjson_path.is_file(): + texts.append(("expected.epJSON", job.expected_epjson_path)) + + for label, path in texts: + outcome = _preserved_text_of(job, label, path, limit) + if outcome.status is not Status.PASSED: + return outcome + return AssertionOutcome(job.case_id, Assertion.PRESERVED_TEXT, Status.PASSED) + + +def _preserved_text_of(job: CaseJob, label: str, path: Path, limit: int) -> AssertionOutcome: + """One text: read it preserving, apply the case's operations, write, compare bytes.""" + try: + preserved = _preserving_round_trip(job, path) + except _ExtentUnknown as error: + # Never a pass. A runner that cannot say where a touched object sits cannot say which bytes + # the assertion excludes, so it has no verdict to give. + return _errored(job.case_id, Assertion.PRESERVED_TEXT, f"{label}: {error}") + except Exception as error: + return _errored( + job.case_id, + Assertion.PRESERVED_TEXT, + f"{label}: the preserving round trip raised {type(error).__name__}: {error}", + ) + + comparison = compare_preserved_text( + preserved.written, + preserved.source, + left_excluded=preserved.written_excluded, + right_excluded=preserved.source_excluded, + ) + extra = (f"comparing the write of {label} against {label} itself",) + return _from_comparison(job.case_id, Assertion.PRESERVED_TEXT, comparison, limit, extra) + + +class _ExtentUnknown(RunnerError): + """The runner could not place a touched object's text on one side or the other.""" + + +def _preserving_round_trip(job: CaseJob, path: Path) -> _Preserved: + """Read ``path`` with preservation, apply the case's operations, and write it back. + + The object notation preserves on all-or-nothing terms in both languages, so a case declaring + operations over one has no per-object extents to exclude and no meaningful narrowing. The + corpus has no such case, and one appearing is a corpus fault rather than something to guess at. + """ + import idfkit + + # `newline=""` is not optional. Text mode translates CRLF to LF on the way in, so a source read + # without it comes back as a file the case does not contain, and the three line-ending cases + # would report a difference in the runner's own reading rather than in the library's writing. + encoding = IDF_ENCODING if path.suffix == ".idf" else "utf-8" + with path.open("r", encoding=encoding, newline="") as handle: + source = handle.read() + + if path.suffix != ".idf": + if job.operations: + raise _ExtentUnknown( + "the case declares operations over an object-notation text, which preserves on " + "all-or-nothing terms and has no per-object extent to exclude" + ) + document = idfkit.parse_epjson(path, preserve_formatting=True) + return _Preserved(source=source, written=idfkit.write_epjson(document)) + + document = idfkit.parse_idf(path, encoding=IDF_ENCODING, preserve_formatting=True) + source_spans = _cst_spans(document) + _apply_operations(document, job.operations) + written = idfkit.write_idf(document) + if not job.operations: + return _Preserved(source=source, written=written) + + written_spans = _written_spans(document, written) + # A removal takes the object's node out of the syntax tree altogether, so a removed object is + # not a node the written walk can report on. It is the difference between the objects the tree + # held when the read finished and the ones it holds now, and its extent is excluded on the + # source side alone: the write produced nothing for it, and there is no written extent to skip. + surviving = {key for key, _ in _cst_spans(document)} + touched = {key for key, _ in written_spans} + touched |= {key for key, _ in source_spans if key not in surviving} + return _Preserved( + source=source, + written=written, + source_excluded=tuple(span for key, span in source_spans if key in touched), + written_excluded=tuple(span for _, span in written_spans), + ) + + +def _cst_spans(document: Any) -> tuple[tuple[int, tuple[int, int]], ...]: + """Where each object's text sits in the text the read was given, keyed by object identity. + + The concrete syntax tree reconstructs the input by concatenation, which is the property its own + test asserts, so accumulating node lengths gives every object's extent without a second scan. + """ + cst = document.cst + if cst is None: + raise _ExtentUnknown("the preserving read produced no concrete syntax tree") + spans: list[tuple[int, tuple[int, int]]] = [] + at = 0 + for node in cst.nodes: + end = at + len(node.text) + if node.obj is not None: + spans.append((id(node.obj), (at, end))) + at = end + return tuple(spans) + + +def _written_spans(document: Any, written: str) -> tuple[tuple[int, tuple[int, int]], ...]: + """Where each TOUCHED object's text sits in what the library wrote. + + Rebuilt from the same two ingredients the preserving writer uses, the syntax tree and the + ordinary formatter, and then checked against what the writer actually produced. A mismatch + means this runner's idea of the output no longer matches the library's, so it raises rather + than excluding the wrong bytes: excluding the wrong bytes is how a broken writer passes. + """ + from idfkit.writers import IDFWriter + + cst = document.cst + if cst is None: + raise _ExtentUnknown("the preserving read produced no concrete syntax tree") + + formatter = IDFWriter(document, output_type="standard") + live = {id(obj) for obj in document.all_objects} + pieces: list[str] = [] + spans: list[tuple[int, tuple[int, int]]] = [] + emitted: set[int] = set() + at = 0 + + def emit(text: str, key: int | None) -> None: + nonlocal at + pieces.append(text) + if key is not None: + spans.append((key, (at, at + len(text)))) + at += len(text) + + # A node's text runs to the end of the blank line that separates it from the next statement, so + # a touched object's WRITTEN extent is its formatted text plus the separator the writer emits + # after it. Excluding only the formatted text would leave that separator to be compared against + # the source's, where the two are different characters in different places, and every edited + # case would fail on the writer's own punctuation rather than on anything it got wrong. + for node in cst.nodes: + if node.obj is None: + emit(node.text, None) + continue + key = id(node.obj) + if key not in live: + # Detached from the document but still in the tree, which the writer skips. A removal + # through `removeidfobject` takes the node out as well, so this branch is the other + # way an object stops being written and it contributes nothing either way. + continue + emitted.add(key) + if node.obj.source_text is not None: + emit(node.obj.source_text, None) + else: + emit(f"{formatter.format_object(node.obj)}\n\n", key) + + added = [obj for obj in document.all_objects if id(obj) not in emitted] + if added: + # The newline that keeps an appended object off the author's last line is written for that + # object and has no counterpart in the source, so it belongs inside its extent too. + tail = pieces[-1] if pieces else "" + lead = "\n" if tail and not tail.endswith("\n") else "" + for obj in added: + emit(f"{lead}{formatter.format_object(obj)}\n\n", id(obj)) + lead = "" + + rebuilt = "".join(pieces) + if rebuilt != written: + raise _ExtentUnknown( + "this runner's reconstruction of the written text does not match what the library " + "wrote, so it cannot say which bytes belong to a touched object. The reconstruction is " + f"{len(rebuilt)} characters and the write is {len(written)}" + ) + return tuple(spans) + + +def _apply_operations(document: Any, operations: Sequence[Operation]) -> None: + """Apply a case's declared changes, in list order, through the library's own mutators. + + The corpus vocabulary is language-neutral and this is where it meets one library. + """ + for operation in operations: + if operation.op is OperationKind.ADD: + fields = { + field: _coerced(document, operation.type, field, value) + for field, value in (operation.fields or {}).items() + } + document.add(operation.type, operation.name, **fields) + continue + + obj = document[operation.type].get(operation.name) + if obj is None: + raise _ExtentUnknown( + f"operation {operation.op.value!r} names {operation.type} {operation.name!r}, " + f"which the document does not hold" + ) + if operation.op is OperationKind.SET_FIELD: + field = operation.field or "" + setattr(obj, field, _coerced(document, operation.type, field, operation.value or "")) + elif operation.op is OperationKind.RENAME: + obj.name = operation.to + else: + document.removeidfobject(obj) + + +def _coerced(document: Any, type_name: str, field: str, value: str) -> Any: + """A case's text turned into the value this library's own reader would have made of it. + + A case says what a FILE would have said, so a numeric field written ``"3.0"`` has to arrive as + the number the reader would have produced. Handing the text straight over instead would make + every ``set-field`` on a numeric field a change even when the case wrote the value already + held, which is the one property ``preserve-edit-no-op`` exists to check. + + The schema decides, not the text: a numeric-looking string in a string field stays a string, + and a sizing sentinel in a numeric field stays a string too, because that is what the reader + does with both. + """ + description = next( + (item for item in document.describe(type_name).fields if item.name == field), + None, + ) + if description is None or description.field_type not in {"number", "integer"}: + return value + try: + number = float(value) + except ValueError: + return value # a sentinel such as Autosize, which the reader also leaves as text + return int(number) if description.field_type == "integer" else number + + def _missing_expectation(job: CaseJob, assertion: Assertion, path: Path) -> AssertionOutcome | None: """The error to report when a Tier 1 expectation file is absent, or ``None`` when it is there. diff --git a/runners/tests/compare_fixtures.json b/runners/tests/compare_fixtures.json index d4daf6f..752e3dc 100644 --- a/runners/tests/compare_fixtures.json +++ b/runners/tests/compare_fixtures.json @@ -28,7 +28,15 @@ "the right as int, JavaScript parses both as number, and both comparators must return no", "difference. Integer-like object keys are avoided throughout, because JavaScript visits those", "ahead of the rest and the two comparators would then disagree on difference order for a reason", - "that has nothing to do with the libraries under test." + "that has nothing to do with the libraries under test.", + "", + "Mode 'text' drives the byte comparator of rule 8, which is the one textual comparison in this", + "suite and is bounded to one direction by rule 1's exception: a library's own output against", + "that library's own input. Its rows carry strings rather than JSON values, optional", + "left_excluded and right_excluded spans naming the extents of the objects a case's operations", + "touched, and an expect list of REGIONS rather than differences: offset, line, column,", + "right_offset and a window of each side. Offsets are into the texts as written, never into the", + "surviving text after the exclusions, because a maintainer opens the file." ], "version": 1, "special_key": "$special", @@ -973,6 +981,74 @@ "right": "InvalidField" } ] + }, + { + "id": "preserved-text-identical", + "rules": [8], + "mode": "text", + "why": "The whole point of the assertion. A preserving write that changed nothing reports nothing", + "left": "Version, 26.1;\n\nZone,\n A;\n", + "right": "Version, 26.1;\n\nZone,\n A;\n", + "expect": [] + }, + { + "id": "preserved-text-one-character-at-offset-zero", + "rules": [8], + "mode": "text", + "why": "Offset zero is where an off-by-one in the prefix walk hides: a region reported at 1 instead of 0 is still a failure and still points at the wrong character", + "left": "version, 26.1;\n", + "right": "Version, 26.1;\n", + "expect": [ + { "offset": 0, "line": 1, "column": 1, "right_offset": 0, "left_text": "v", "right_text": "V" } + ] + }, + { + "id": "preserved-text-lost-trailing-newline", + "rules": [8], + "mode": "text", + "why": "The hazard preserve-no-trailing-newline exists for, and the one an ordinary diff reports worst: the whole file agrees and one byte at the end does not", + "left": "Zone,\n A;", + "right": "Zone,\n A;\n", + "expect": [ + { "offset": 10, "line": 2, "column": 5, "right_offset": 10, "left_text": "", "right_text": "\n" } + ] + }, + { + "id": "preserved-text-three-regions", + "rules": [8], + "mode": "text", + "why": "Three separated differences are three regions and not one, and not forty-three: the resynchronisation rule decides that, and --max-differences counts what this returns", + "left": "AAAAAAAAAA1BBBBBBBBBB2CCCCCCCCCC3DDDDDDDDDD", + "right": "AAAAAAAAAAxBBBBBBBBBByCCCCCCCCCCzDDDDDDDDDD", + "expect": [ + { "offset": 10, "line": 1, "column": 11, "right_offset": 10, "left_text": "1", "right_text": "x" }, + { "offset": 21, "line": 1, "column": 22, "right_offset": 21, "left_text": "2", "right_text": "y" }, + { "offset": 32, "line": 1, "column": 33, "right_offset": 32, "left_text": "3", "right_text": "z" } + ] + }, + { + "id": "preserved-text-touched-extent-is-not-compared", + "rules": [8], + "mode": "text", + "why": "FR-023: an edited object's own text is each library's ordinary formatting and legitimately differs, so it is excluded and everything outside it still has to match", + "left": "Zone,\n A,\n 4.5;\n\nZone,\n B;\n", + "right": "Zone,\n A,\n 3.0;\n\nZone,\n B;\n", + "left_excluded": [[0, 21]], + "right_excluded": [[0, 17]], + "expect": [] + }, + { + "id": "preserved-text-offset-maps-past-an-excluded-extent", + "rules": [8], + "mode": "text", + "why": "A region found after an exclusion must report an offset into the file a maintainer can open, not into the stitched-up surviving text nobody has", + "left": "Zone,\n A,\n 4.5;\n\nZone,\n B;", + "right": "Zone,\n A,\n 3.0;\n\nZone,\n B;\n", + "left_excluded": [[0, 21]], + "right_excluded": [[0, 17]], + "expect": [ + { "offset": 33, "line": 6, "column": 5, "right_offset": 29, "left_text": "", "right_text": "\n" } + ] } ] } diff --git a/runners/tests/test-compare.mjs b/runners/tests/test-compare.mjs index e8643de..c31fabf 100644 --- a/runners/tests/test-compare.mjs +++ b/runners/tests/test-compare.mjs @@ -19,7 +19,15 @@ import { readFileSync } from 'node:fs'; import test from 'node:test'; import * as compare from '../compare.mjs'; -import { ABSENT, Absent, Comparison, Difference, DifferenceKind } from '../compare.mjs'; +import { + ABSENT, + Absent, + Comparison, + Difference, + DifferenceKind, + TextComparison, + TextRegion, +} from '../compare.mjs'; import { Assertion, Library, ParseOutcome } from '../model.mjs'; const FIXTURE_FILE = new URL('./compare_fixtures.json', import.meta.url); @@ -27,7 +35,7 @@ const FIXTURE_FILE = new URL('./compare_fixtures.json', import.meta.url); // Every rule in compare.md that a fixture can exercise. Rule 6, the encoding, belongs to the // runner: values reach the comparator already decoded, so the fixtures pin only that a decoded high // byte compares as itself. -const RULES = [1, 2, 3, 4, 5, 6, 7]; +const RULES = [1, 2, 3, 4, 5, 6, 7, 8]; /** * Which comparator a fixture drives. @@ -38,6 +46,7 @@ const RULES = [1, 2, 3, 4, 5, 6, 7]; const Mode = Object.freeze({ VALUES: 'values', UNORDERED: 'unordered', + TEXT: 'text', }); /** @@ -49,13 +58,15 @@ const Mode = Object.freeze({ * @property {string} why * @property {*} left * @property {*} right - * @property {readonly Difference[]} expect + * @property {readonly Difference[] | readonly TextRegion[]} expect + * @property {readonly (readonly [number, number])[]} [leftExcluded] + * @property {readonly (readonly [number, number])[]} [rightExcluded] */ /** One row of the shared table, decoded. */ class Fixture { /** @param {FixtureFields} fields */ - constructor({ id, rules, mode, path, why, left, right, expect }) { + constructor({ id, rules, mode, path, why, left, right, expect, leftExcluded = [], rightExcluded = [] }) { this.id = id; this.rules = Object.freeze([...rules]); this.mode = mode; @@ -64,6 +75,8 @@ class Fixture { this.left = left; this.right = right; this.expect = Object.freeze([...expect]); + this.leftExcluded = Object.freeze(leftExcluded.map(([start, end]) => Object.freeze([start, end]))); + this.rightExcluded = Object.freeze(rightExcluded.map(([start, end]) => Object.freeze([start, end]))); Object.freeze(this); } } @@ -118,6 +131,32 @@ function loadFixtures() { return value; } + /** + * A text row records regions rather than differences, because rule 8 reports a place in a file + * and not a path into a value. + * + * @param {*} row + * @returns {readonly Difference[] | readonly TextRegion[]} + */ + function expectation(row) { + if (row.mode === Mode.TEXT) { + return row.expect.map( + (entry) => + new TextRegion({ + offset: entry.offset, + line: entry.line, + column: entry.column, + rightOffset: entry.right_offset, + leftText: entry.left_text, + rightText: entry.right_text, + }) + ); + } + return row.expect.map( + (entry) => new Difference(entry.kind, entry.path, decode(entry.left), decode(entry.right)) + ); + } + const fixtures = document.fixtures.map( (row) => new Fixture({ @@ -128,9 +167,9 @@ function loadFixtures() { why: row.why, left: decode(row.left), right: decode(row.right), - expect: row.expect.map( - (entry) => new Difference(entry.kind, entry.path, decode(entry.left), decode(entry.right)) - ), + expect: expectation(row), + leftExcluded: row.left_excluded ?? [], + rightExcluded: row.right_excluded ?? [], }) ); return { fixtures, specialKey, document }; @@ -194,6 +233,12 @@ function run(fixture) { if (fixture.mode === Mode.VALUES) { return compare.compareValues(fixture.left, fixture.right, { path: fixture.path }); } + if (fixture.mode === Mode.TEXT) { + return compare.comparePreservedText(fixture.left, fixture.right, { + leftExcluded: fixture.leftExcluded, + rightExcluded: fixture.rightExcluded, + }); + } return compare.compareUnordered(fixture.left, fixture.right, { path: fixture.path }); } @@ -215,17 +260,21 @@ for (const fixture of FIXTURES) { test(fixture.id, () => { // The comparator returns exactly the differences the table records, in the table's order. const result = run(fixture); - assert.deepStrictEqual(shape(result.differences), shape(fixture.expect), fixture.why); - for (const [index, observed] of result.differences.entries()) { - const expected = fixture.expect[index]; - assert.ok( - same(observed.left, expected.left), - `${fixture.id}: left value at ${expected.path}` - ); - assert.ok( - same(observed.right, expected.right), - `${fixture.id}: right value at ${expected.path}` - ); + if (result instanceof TextComparison) { + assert.deepStrictEqual([...result.regions], [...fixture.expect], fixture.why); + } else { + assert.deepStrictEqual(shape(result.differences), shape(fixture.expect), fixture.why); + for (const [index, observed] of result.differences.entries()) { + const expected = fixture.expect[index]; + assert.ok( + same(observed.left, expected.left), + `${fixture.id}: left value at ${expected.path}` + ); + assert.ok( + same(observed.right, expected.right), + `${fixture.id}: right value at ${expected.path}` + ); + } } assert.equal(result.equal, fixture.expect.length === 0); assert.equal(result.count, fixture.expect.length); @@ -241,7 +290,9 @@ test('fixture ids are unique', () => { test('every difference kind is pinned', () => { // A kind no fixture produces is a kind the two comparators can disagree about. const produced = new Set( - FIXTURES.flatMap((fixture) => fixture.expect.map((difference) => difference.kind)) + FIXTURES.filter((fixture) => fixture.mode !== Mode.TEXT).flatMap((fixture) => + fixture.expect.map((difference) => difference.kind) + ) ); assert.deepStrictEqual([...produced].sort(), Object.values(DifferenceKind).slice().sort()); }); @@ -571,3 +622,29 @@ test('unmatched elements are reported left side first, each in its own document ] ); }); + +test('a byte comparison truncates by region and says so', () => { + // Rule 8: `--max-differences` counts differing REGIONS, and truncation reports the total. The + // alternative, counting characters, makes the flag useless on the failure this assertion + // actually produces: two texts that diverge from offset zero are one place to look at, not six + // hundred thousand findings. + const fixture = FIXTURES.find((item) => item.id === 'preserved-text-three-regions'); + const result = run(fixture); + assert.equal(result.count, 3); + + const lines = result.render({ limit: 2 }); + assert.equal(lines.length, 3); + assert.equal(lines[lines.length - 1], '... and 1 more differing region(s), 3 in total'); + assert.deepStrictEqual([...result.render()], [...result.render({ limit: null })]); +}); + +test('a byte comparison never prints a whole file', () => { + // Rule 8's window is a cap, not a suggestion. A 600 KB failure is still one readable line. + const right = 'A'.repeat(200000); + const left = 'B'.repeat(200000); + const result = compare.comparePreservedText(left, right); + assert.equal(result.count, 1); + const line = result.render()[0]; + assert.ok(line.length < 400); + assert.ok(line.includes('(+199920 more)')); +}); diff --git a/runners/tests/test_compare.py b/runners/tests/test_compare.py index 39261f8..4293b65 100644 --- a/runners/tests/test_compare.py +++ b/runners/tests/test_compare.py @@ -26,7 +26,7 @@ import compare # noqa: E402 import pytest # noqa: E402 -from compare import Comparison, Difference, DifferenceKind # noqa: E402 +from compare import Comparison, Difference, DifferenceKind, TextComparison, TextRegion # noqa: E402 from model import Assertion, Library, ParseOutcome # noqa: E402 FIXTURE_FILE: Final = Path(__file__).resolve().parent / "compare_fixtures.json" @@ -34,7 +34,7 @@ # Every rule in compare.md that a fixture can exercise. Rule 6, the encoding, belongs to the # runner: values reach the comparator already decoded, so the fixtures pin only that a decoded high # byte compares as itself. -RULES: Final = frozenset({1, 2, 3, 4, 5, 6, 7}) +RULES: Final = frozenset({1, 2, 3, 4, 5, 6, 7, 8}) class Mode(str, Enum): @@ -42,6 +42,7 @@ class Mode(str, Enum): VALUES = "values" UNORDERED = "unordered" + TEXT = "text" @dataclass(frozen=True, slots=True) @@ -55,7 +56,9 @@ class Fixture: why: str left: Any right: Any - expect: tuple[Difference, ...] + expect: tuple[Difference, ...] | tuple[TextRegion, ...] + left_excluded: tuple[tuple[int, int], ...] = () + right_excluded: tuple[tuple[int, int], ...] = () def _load_fixtures() -> tuple[tuple[Fixture, ...], str]: @@ -82,6 +85,34 @@ def decode(value: Any) -> Any: return [decode(member) for member in value] return value + def spans(row: Any, key: str) -> tuple[tuple[int, int], ...]: + return tuple((int(start), int(end)) for start, end in row.get(key, ())) + + def expectation(row: Any) -> tuple[Any, ...]: + # A text row records regions rather than differences, because rule 8 reports a place in a + # file and not a path into a value. + if row["mode"] == Mode.TEXT.value: + return tuple( + TextRegion( + offset=entry["offset"], + line=entry["line"], + column=entry["column"], + right_offset=entry["right_offset"], + left_text=entry["left_text"], + right_text=entry["right_text"], + ) + for entry in row["expect"] + ) + return tuple( + Difference( + kind=DifferenceKind(entry["kind"]), + path=entry["path"], + left=decode(entry["left"]), + right=decode(entry["right"]), + ) + for entry in row["expect"] + ) + fixtures = tuple( Fixture( id=row["id"], @@ -91,15 +122,9 @@ def decode(value: Any) -> Any: why=row["why"], left=decode(row["left"]), right=decode(row["right"]), - expect=tuple( - Difference( - kind=DifferenceKind(entry["kind"]), - path=entry["path"], - left=decode(entry["left"]), - right=decode(entry["right"]), - ) - for entry in row["expect"] - ), + expect=expectation(row), + left_excluded=spans(row, "left_excluded"), + right_excluded=spans(row, "right_excluded"), ) for row in document["fixtures"] ) @@ -134,10 +159,17 @@ def _same(left: Any, right: Any) -> bool: return False -def _run(fixture: Fixture) -> Comparison: +def _run(fixture: Fixture) -> Comparison | TextComparison: """Drive the comparator the fixture names.""" if fixture.mode is Mode.VALUES: return compare.compare_values(fixture.left, fixture.right, path=fixture.path) + if fixture.mode is Mode.TEXT: + return compare.compare_preserved_text( + fixture.left, + fixture.right, + left_excluded=fixture.left_excluded, + right_excluded=fixture.right_excluded, + ) return compare.compare_unordered(fixture.left, fixture.right, path=fixture.path) @@ -155,10 +187,13 @@ def _shape(differences: tuple[Difference, ...]) -> list[tuple[str, str]]: def test_fixture_verdict(fixture: Fixture) -> None: """The comparator returns exactly the differences the table records, in the table's order.""" result = _run(fixture) - assert _shape(result.differences) == _shape(fixture.expect), fixture.why - for observed, expected in zip(result.differences, fixture.expect, strict=True): - assert _same(observed.left, expected.left), f"{fixture.id}: left value at {expected.path}" - assert _same(observed.right, expected.right), f"{fixture.id}: right value at {expected.path}" + if isinstance(result, TextComparison): + assert result.regions == fixture.expect, fixture.why + else: + assert _shape(result.differences) == _shape(fixture.expect), fixture.why + for observed, expected in zip(result.differences, fixture.expect, strict=True): + assert _same(observed.left, expected.left), f"{fixture.id}: left value at {expected.path}" + assert _same(observed.right, expected.right), f"{fixture.id}: right value at {expected.path}" assert result.equal is (not fixture.expect) assert result.count == len(fixture.expect) @@ -171,7 +206,12 @@ def test_fixture_ids_are_unique() -> None: def test_every_difference_kind_is_pinned() -> None: """A kind no fixture produces is a kind the two comparators can disagree about.""" - produced = {difference.kind for fixture in FIXTURES for difference in fixture.expect} + produced = { + difference.kind + for fixture in FIXTURES + if fixture.mode is not Mode.TEXT + for difference in fixture.expect + } assert produced == set(DifferenceKind) @@ -358,3 +398,31 @@ def test_deeply_nested_values_are_compared_all_the_way_down() -> None: right = {"child": right} result = compare.compare_values(left, right) assert _shape(result.differences) == [("value", "/child" * depth)] + + +def test_a_byte_comparison_truncates_by_region_and_says_so() -> None: + """Rule 8: ``--max-differences`` counts differing REGIONS, and truncation reports the total. + + The alternative, counting characters, makes the flag useless on the failure this assertion + actually produces: two texts that diverge from offset zero are one place to look at, not six + hundred thousand findings. + """ + fixture = next(item for item in FIXTURES if item.id == "preserved-text-three-regions") + result = _run(fixture) + assert result.count == 3 + + lines = result.render(limit=2) + assert len(lines) == 3 + assert lines[-1] == "... and 1 more differing region(s), 3 in total" + assert result.render() == result.render(limit=None) + + +def test_a_byte_comparison_never_prints_a_whole_file() -> None: + """Rule 8's window is a cap, not a suggestion. A 600 KB failure is still one readable line.""" + right = "A" * 200_000 + left = "B" * 200_000 + result = compare.compare_preserved_text(left, right) + assert result.count == 1 + line = result.render()[0] + assert len(line) < 400 + assert "(+199920 more)" in line From d1615ce27f8a4afd6a1548b13cafb9983af71060 Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Sun, 6 Sep 2026 09:34:57 -0400 Subject: [PATCH 2/3] Register the names the preserving writer adds, before it adds them `check-naming-register.mjs` reads the surface each package's exports map points at, so the change that adds `rawText` to a built .d.ts is the change the gate fires on. The register has to be published at a tag first, and this is that tag. `the original source text` becomes aligned on `IdfDocument.rawText`. It was divergent only because the second language had nothing to put in the column, and it is the minimum a consumer needs to answer whether a write will preserve without reaching into the layer. `the concrete syntax tree` becomes `the retained source structure` and stays divergent, with a reason that is true after this feature rather than before it. The old reason said TypeScript had no counterpart "because it has no lossless round-trip yet", which stops being true in the change that lands the writer; a divergence reason describing an absence that has ended reads as a gap nobody closed. The two languages really do retain different material for the same purpose, a flat list of text runs against the syntax layer plus a statement index, and the old concept name named Python's implementation of the idea rather than the idea, so the TypeScript column could only ever be empty. The anchoring and the touched record get no entry. Neither is public in either language, and the register governs public names. --- governance/naming.toml | 57 +++++++++++++++++++++++++++--------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/governance/naming.toml b/governance/naming.toml index 66ec63e..1a5739b 100644 --- a/governance/naming.toml +++ b/governance/naming.toml @@ -1787,9 +1787,13 @@ typescript = "preserveFormatting" kind = "aligned" notes = """ An option, not a function: `load_idf(path, preserve_formatting=True)` builds a concrete syntax tree -that `write_idf` reproduces, and `load_epjson` retains the raw JSON text the same way. Registered -before the TypeScript side is written; the parity ledger records the absence as Tier 2 under -`lossless-round-trip`. Registering the option name now costs nothing and is breaking later. +that `write_idf` reproduces, and `load_epjson` retains the raw JSON text the same way. TypeScript +retains the syntax layer and an index from statement to object instead, and `writeIdf` walks the two +together; `parseEpJson` retains the text the same way Python does. + +Registered at `governance-2026.12`, before either side of the TypeScript half existed, on the +reasoning that registering an option name costs nothing then and is breaking later. It cost nothing: +this feature made the registered name true rather than choosing one. """ rename_count = { python = 0, typescript = 0 } @@ -3179,7 +3183,7 @@ whose version is not yet known. Python tokenises inside `IDFParser.parse` with a regex and exposes no lexer. Exporting one would be a second public parse path over the same text, which FR-005 prohibits, and it would publish an internal -representation that the concrete syntax tree, registered above as `preserve formatting on a +representation that the retained source structure, registered above as `preserve formatting on a round-trip`, already covers for the one case a caller has a reason to see. Excluded is terminal. A Python counterpart appearing later fails the gate. @@ -3287,8 +3291,8 @@ rename_count = { python = 0, typescript = 0 } # `IDFDocument` in Python and `IdfDocument` in TypeScript are the same class under the acronym casing # rule recorded above. Their members are not all the same, and the ones that differ do so for reasons # already recorded elsewhere in this file: Python's document is dict-like and TypeScript's is not, -# Python holds a concrete syntax tree and TypeScript does not, and Python's operations that touch a -# disk are synchronous. +# Python and TypeScript retain different material for a preserving write, and Python's operations +# that touch a disk are synchronous. # # The per-object-type accessors the Python stub generates are NOT members for this purpose. They are # one entry, `generated object types`, and both gates recognise them through the machinery that emits @@ -3343,31 +3347,44 @@ name, same meaning, and the optionality difference follows from `create a new do rename_count = { python = 0, typescript = 0 } [[entry]] -concept = "the concrete syntax tree" +concept = "the retained source structure" python = "IDFDocument.cst" typescript = "" kind = "divergent" divergence_reason = """ -Present only when the document was read with `preserve_formatting=True`, and it is what lets -`write_idf` reproduce the original text byte for byte. +Both languages retain the material a preserving write consumes, in different shapes. Python holds a +concrete syntax tree, a flat list of text runs each optionally anchored to one object. TypeScript +holds the syntax layer feature 005 built for this reader, plus an index from statement to object, and +keeps it internal: `scanIdf` already exports the layer to a caller who wants one, so exposing a +second path to it through the document would publish a structure nothing reads. The capability is +complete in both under `lossless-round-trip`; only the internal representation differs. + +The reason used to be that TypeScript had no lossless round-trip. That stopped being true when the +preserving writer landed, and a divergence reason that describes an absence which has ended is worse +than no reason at all: it reads as a gap nobody closed. This one states the difference that is +actually there, which is a difference in representation behind an aligned capability. -TypeScript has no counterpart because it has no lossless round-trip yet: `writeIdf`'s own doc comment -states the caveat plainly, that `3.0` comes back as `3` because JavaScript has one number type and -the distinction is lost at parse time. The parity ledger records the absence under -`lossless-round-trip` as Tier 2, and `preserve formatting on a round-trip` above registers the option -name ahead of it. +The concept was spelled "the concrete syntax tree" until `governance-2026.13`. It was renamed because +the old spelling named Python's implementation of the idea rather than the idea, so the TypeScript +column could only ever be empty: there is no TypeScript concrete syntax tree and there is not meant +to be one. """ rename_count = { python = 0, typescript = 0 } [[entry]] concept = "the original source text" python = "IDFDocument.raw_text" -typescript = "" -kind = "divergent" -divergence_reason = """ -The other half of the formatting-preserving round-trip, beside `cst`: the source as it was read, kept -only when `preserve_formatting=True`. Absent from TypeScript for the same reason and recorded under -the same parity entry. +typescript = "IdfDocument.rawText" +kind = "aligned" +notes = """ +The text the document was read from, kept only when the read asked for it. Present in both formats: +for the text format it is the syntax layer's own text, and for the object notation it is the whole of +what preservation has to work with, which is why that format's terms are all-or-nothing. + +Divergent until `governance-2026.13`, and only because the second language had nothing to put in the +column. It is the minimum a consumer needs to answer whether a write will preserve without reaching +into the layer, which is why it is the one piece of the retained material that is public on both +sides. """ rename_count = { python = 0, typescript = 0 } From 086d5633ab61ed4b1f2daf71d063c31087e8fafa Mon Sep 17 00:00:00 2001 From: Samuel Letellier-Duchesne Date: Sun, 6 Sep 2026 09:56:36 -0400 Subject: [PATCH 3/3] The preserving writer exists in both languages, so the record says so 46 typescript entries against idfkit-js#12 went stale the moment the writer landed, and a case passing while its entry remains is a failure rather than a pass. They are removed here, which is what conformance-2026.10 is: conformance-2026.9 with those entries gone. The parity ledger moves `lossless-round-trip` to complete on both sides, and `absence_kind` and `issue` go with the absence they recorded. `names` gains the two register concepts this feature made true. Complete rather than partial, on both sides. Both formats are in scope, so neither language carries a gap. The object notation's all-or-nothing terms differ from the text format's per-object terms in both languages equally, which is a property of the format rather than a difference between the libraries, and the entry's comment block states it so a reader who knows one format does not assume the other's terms. --- governance/parity.toml | 18 +- known-divergence.toml | 446 ++--------------------------------------- manifest.json | 2 +- runners/run.mjs | 66 ++++-- 4 files changed, 73 insertions(+), 459 deletions(-) diff --git a/governance/parity.toml b/governance/parity.toml index 51f1f91..c06e9b1 100644 --- a/governance/parity.toml +++ b/governance/parity.toml @@ -579,16 +579,28 @@ names = [ # write_idf; load_epjson(preserve_formatting=True) retaining the raw JSON text # TypeScript: none. packages/core/src/write/idf.ts states plainly that it does not round-trip # formatting, and `3.0` comes back as `3`. +# Both languages preserve the object notation on all-or-nothing terms: the retained text is +# reproduced only while nothing has been touched, added or removed, and any change falls the whole +# document back to the ordinary writer. The text format preserves per object. Stated here because a +# reader who knows the text format's terms would otherwise assume them, and because that difference +# is a property of the FORMAT rather than a difference between the libraries, which is why this +# entry is complete on both sides rather than partial on either. +# +# One limit each side shares and neither closes: reproduction is defined against the text the read +# was GIVEN, not against a file's bytes. Python's `save_idf` opens its destination without +# `newline=""`, so the standard library translates line endings on the way out, and the TypeScript +# reader refuses a file carrying a byte-order mark. Both live at the file boundary, both are +# recorded in the corpus README's coverage-gaps section, and neither is reachable by a corpus case. [[capability]] id = "lossless-round-trip" title = "Formatting-preserving round-trip" tier = "tier-2" python = "complete" -typescript = "absent" -absence_kind = "not-yet" -issue = "https://github.com/idfkit/idfkit-js/issues/12" +typescript = "complete" names = [ "preserve formatting on a round-trip", + "the original source text", + "the retained source structure", ] # Python: Vector3D, Polygon3D, calculate_surface_area, calculate_surface_azimuth, diff --git a/known-divergence.toml b/known-divergence.toml index 4f90bb8..1a60a76 100644 --- a/known-divergence.toml +++ b/known-divergence.toml @@ -15,16 +15,13 @@ # either side alone turns its entries stale, which the runner reports as a failure, so those four # issues must land together. # -# Counts, against idfkit 1.0.0rc2 and @idfkit/core 0.2.0, EnergyPlus 26.1.0: 68 entries over 13 -# issues and 57 cases, 16 against python and 52 against typescript, on a corpus of 69 cases and 211 +# Counts, against idfkit 1.0.0rc2 and @idfkit/core 0.2.0, EnergyPlus 26.1.0: 22 entries over 13 +# issues and 13 cases, 16 against python and 6 against typescript, on a corpus of 69 cases and 211 # assertions. Four entries went away in e60b74d, when both libraries started agreeing about blank # names. This header stated 22 entries over 47 cases until 2026-09-04, and 20 over 12 cases until # the preserved-text assertion landed; it is recounted from the file and from the runner's own # tally rather than adjusted, and it is recounted again whenever a case lands. -# -# 46 of the 52 typescript entries are the one absence recorded 46 times, which is what the (case, -# library, assertion) unit of exception costs when a whole capability is missing. They go together, -# in the change that lands the writer, and the count corrects itself here when they do. + # # The tier1-introspection-field-shapes entry was removed on 2026-09-04, in the change that closed # the introspection capability. It was the one entry that recorded no bug: it pinned a divergence @@ -227,437 +224,18 @@ observed = '''Nothing to compare: the read raised VersionNotFoundError before a expected = '''Re-parsing Python's own IDF output deep-equals the original document, Version object included.''' # --------------------------------------------------------------------------- -# preserved-text: the second language has no preserving write yet +# preserved-text: the absence ended, and its 46 entries went with it # --------------------------------------------------------------------------- # -# One entry per case carrying the assertion, all naming idfkit-js 12, which is the issue the parity -# ledger already points at for this capability. They are mechanical and differ only in the case -# name, because the corpus has no way to say "this library lacks this capability" once: its unit of -# exception is the (case, library, assertion) triple, and that granularity is what makes a stale -# entry detectable. They are removed in one change, the change that lands the writer. +# `conformance-2026.9` carried one typescript entry per case declaring this assertion, 46 of them, +# all naming idfkit-js 12. The assertion was cut while the second language failed it, which is what +# made the corpus the specification of the behaviour rather than a check applied afterwards. # -# The assertion was cut at conformance-2026.9 while the second language still failed it, which is -# FR-025 and is what makes the corpus the specification of the behaviour rather than a check -# applied afterwards. -# -# Three cases carrying the assertion have no entry, because they are not failing: preserve-empty, -# preserve-comments-only and preserve-duplicate-name declare a parse failure both libraries agree -# on, so there is no document and the assertion is skipped in both. preserve-unterminated-final has -# none either, for the same reason on the TypeScript side alone. - -[[divergence]] -case = "encoding-latin1-high-bytes" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "encoding-utf8-object-name" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "extensible-empty-group" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "extensible-partial-group" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "extensible-schedule-compact-numeric-field" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "extensible-single-vs-multiple" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "extensible-wrapper-key" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "naming-blank-vs-absent" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "naming-synthetic-collision" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "naming-synthetic-key" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "numeric-autocalculate-casing" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "numeric-autosize-casing" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "numeric-integer-field" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "numeric-scientific-notation" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "numeric-sentinel-schema-remap" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "numeric-zero-against-blank" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "positional-extensible-group-trailing-unset" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "positional-trailing-unset-before-extensible" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "positional-trailing-unset-no-extensible" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "preserve-comment-hazards" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "preserve-edit-no-op" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "preserve-edit-one-field" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "preserve-edit-remove-and-add" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "preserve-edit-rename" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "preserve-line-endings-crlf" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "preserve-line-endings-mixed" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "preserve-no-trailing-newline" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "preserve-value-across-lines" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "references-case-insensitive-match" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "references-dangling-name" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "references-self-referential" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "types-choice-field-casing" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "types-object-type-name-casing" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "versions-22-1-0" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "versions-22-2-0" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "versions-23-1-0" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "versions-23-2-0" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "versions-24-1-0" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "versions-24-2-0" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "versions-25-1-0" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "versions-25-2-0" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "versions-26-1-0" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "versions-9-5-0" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "versions-9-6-0" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "versions-canonical-string" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' - -[[divergence]] -case = "versions-late-version-object" -library = "typescript" -assertion = "preserved-text" -issue = "https://github.com/idfkit/idfkit-js/issues/12" -observed = '''No preserving write exists. writeIdf reformats every object, and its own doc comment -states the caveat: 3.0 comes back as 3 because JavaScript has one number type.''' -expected = '''The written text equals the input byte for byte.''' +# `conformance-2026.10` is that level with those entries gone. They were removed by the change that +# landed the preserving writer, because a case passing while its entry remains is a failure and not +# a pass: the runner collects stale entries and fails the run. This is the second time the corpus +# has done that, after the tier1-introspection-field-shapes entry in the change that closed +# introspection. # --------------------------------------------------------------------------- # preserved-text and parse-outcome: two first-language bugs that were already tracked diff --git a/manifest.json b/manifest.json index 633e421..380b3f2 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "$schema": "./manifest.schema.json", "schema_version": 1, - "corpus_level": "conformance-2026.9", + "corpus_level": "conformance-2026.10", "oracle": [ { "id": "encoding-latin1-high-bytes", diff --git a/runners/run.mjs b/runners/run.mjs index c4128f1..a55a844 100644 --- a/runners/run.mjs +++ b/runners/run.mjs @@ -1261,24 +1261,61 @@ function reconcileSpans(job, source, written, sourceSpans, writtenSpans) { const sourceMiddle = sourceSpans.slice(head, sourceSpans.length - tail); const writtenMiddle = writtenSpans.slice(head, writtenSpans.length - tail); - if (sourceMiddle.length === 0 && writtenMiddle.length === 0 && job.operations.length > 0) { - throw new ExtentUnknown( - 'the case declares operations and the written text reproduces every statement of the ' + - 'source, so no statement can be identified as the one that changed' - ); - } + // Nothing differs, so nothing is excluded and the whole text is compared. That is the right + // answer for `preserve-edit-no-op`, whose operation writes the value already held, and it is + // also what this assertion has to say about a library that discarded an edit: every byte outside + // the objects it touched equals the input, and it touched none. Whether an edit SURVIVED is a + // different claim, made over a re-read of the output, and it is not this one. // One span per side, from the first differing statement to the last, plus the separator that // follows it: the writer's own punctuation around a reformatted object is written for that // object and has no counterpart in the source. return { source, written, - sourceExcluded: sourceMiddle.length === 0 ? [] : [enclosing(source, sourceMiddle)], - writtenExcluded: writtenMiddle.length === 0 ? [] : [enclosing(written, writtenMiddle)], + // The source side excludes the statements themselves and nothing more. What follows a + // statement's terminator belongs to no object, and a preserving write copies it, so it is + // present on both sides and is compared. + sourceExcluded: sourceMiddle.length === 0 ? [] : [span(sourceMiddle)], + // The written side excludes what the WRITER produced for those objects, which runs past the + // terminator: `writeObject` puts its own `!- Field Name` comment after the semicolon and ends + // the line. The source's own trailing comment is then copied into the gap after it, which is + // why a reformatted object leaves the old comment below the new text, and why that copied gap + // is the same on both sides and stays compared. + writtenExcluded: writtenMiddle.length === 0 ? [] : [throughLine(written, span(writtenMiddle))], }; } +/** + * One span covering every statement given. + * + * @param {[number, number][]} spans + * @returns {[number, number]} + */ +function span(spans) { + return [spans[0][0], spans[spans.length - 1][1]]; +} + +/** + * A span extended to the end of the line its last character sits on, and over any blank lines + * after it. + * + * The line, because the writer's own field comment follows the terminator. The blank lines, + * because an object appended at the end of the file is followed by the separator the writer adds + * and by nothing in the source at all. + * + * @param {string} text + * @param {[number, number]} bounds + * @returns {[number, number]} + */ +function throughLine(text, bounds) { + let end = bounds[1]; + while (end < text.length && text[end] !== '\n') end += 1; + if (end < text.length) end += 1; + while (end < text.length && (text[end] === '\n' || text[end] === '\r')) end += 1; + return [bounds[0], end]; +} + /** * @param {string} text * @param {[number, number]} span @@ -1301,19 +1338,6 @@ function gapBefore(text, spans, index) { return text.slice(from, spans[index][0]); } -/** - * One span covering every statement in `spans`, and the blank text that trails the last of them. - * - * @param {string} text - * @param {[number, number][]} spans - * @returns {[number, number]} - */ -function enclosing(text, spans) { - const start = spans[0][0]; - let end = spans[spans.length - 1][1]; - while (end < text.length && (text[end] === '\n' || text[end] === '\r')) end += 1; - return [start, end]; -} /** * Apply a case's declared changes, in list order, through the library's own mutators.