Skip to content

Describe a type with its prose, carry a parse error's findings, and close the writer controls - #37

Merged
samuelduchesne merged 14 commits into
amend-install-size-budgetfrom
002-close-tier1-partials
Sep 4, 2026
Merged

Describe a type with its prose, carry a parse error's findings, and close the writer controls#37
samuelduchesne merged 14 commits into
amend-install-size-budgetfrom
002-close-tier1-partials

Conversation

@samuelduchesne

Copy link
Copy Markdown
Contributor

Feature 002, the TypeScript half. Stacked on #33: this targets amend-install-size-budget, because the prose below needs the budget that PR raises.

Depends on idfkit/idfkit-conformance#1. This bumps the governance pin to governance-2026.10 and the conformance pin to conformance-2026.8, neither of which exists until that merges and the tags are cut, so the gates are red until then.

Describing a type now reads the same in both languages

Three families of difference, all in what the schema bundle carried rather than in the introspection code.

The prose. @idfkit/schemas dropped memo and note by omission: slimType and slimField never read them. It now emits them as a deduplicated pool to data/docs.json.gz, 4,878 distinct strings standing in for roughly 119,000 occurrences.

describeObjectType takes the pool as an optional third argument and stays synchronous. Reading a file is not, and making the function async to fetch something most callers do not want would be a breaking change serving the minority. A caller who passes nothing gets exactly what they got before.

The prose never reaches the model-reading path: it is a separate file under data/, which check-bundle-purity.mjs already fences, and the check now covers it with no change.

The accepted values. The blank is legal for 21,962 enum-bearing fields and Python reports it. It stays filtered out of e, because e is what validation checks against and admitting it there would change what validate() accepts; eb carries it alongside and the description path puts it back. se, the collapsed anyOf string branch, was already in the bundle and already read by validation; the description path now reads it too.

The field order. Three types in every one of the seventeen schemas carry a positional list holding only the name, so the description fell back to the key order of the property map, which the content-addressing serializer had sorted. fo records the declaration order. Two of the three actually diverged; the third agreed by alphabetical luck and is fixed anyway.

Proven against Python rather than against our own assumptions: a digest over every memo and note of all 858 types in 26.1.0 matches the one Python produces.

The cost, and it is not what the plan predicted

The plan expected the prose to add 190,471 bytes and land the install at 86.2 percent of budget. It adds 310,253 and lands at 92.9 percent, 1,705,108 bytes against 1,835,008, with 126.9 KB of headroom.

The pool itself is 175,035 bytes, near the prediction. The references cost 135,218 rather than 18,652, because a memo that differs between versions makes two previously byte-identical definitions distinct: unique defs go from 2,570 to 2,735. The estimate did not account for deduplication degrading. Measured by rebuilding without the references, eb and fo together cost nothing at all.

The gate passes and the budget is not moved again to make it pass.

A parse error carries its findings

IdfParseError held one diagnostic flattened into .line and .typeName, and a caller who wanted the rest had nowhere to look. It now carries the collection, and both accessors still resolve to the first finding's values so no existing caller breaks.

ParseDiagnostic gains a code from the shared vocabulary, plus a column, a filepath and an objectName. The first pass declared those three and filled none of them, which made the location parity a type declaration; the lexer now tracks line starts and reports the column of the object's first non-blank character, which is what Python reports, and the path is attached at the node edge on both the returning and raising paths.

The writer controls

compressed, the fifth control, which had no compact path here to extend. Then ordering, added in a second pass because SC-007 asks for zero controls that exist on one writer and not the other and this one still did.

ordering also makes the corpus case honest: it asked for source, already this writer's only behaviour, so it passed even when the runner dropped the option entirely.

No default moves. All six are pinned by tests first, at the values this writer uses: four-space indent, column 30, insertion order, schema-consulted floats, minor words lowercase, no generator header. Five of the six differ from the other language and none of them moves.

Known, and tracked rather than fixed here

#36, a missing semicolon corrupts a field and the parse reports nothing. Recorded in known-divergence.toml. The same defect exists in idfkit as #191, and the two must be fixed together or the corpus reports whichever moved first as a new divergence.

Review

A maintainer of the other language, per FR-067 and CODEOWNERS.

Verification

475 tests, tsc --build, format:check, and every check:* gate green, at the tags once they exist.

Closes the introspection entry on the parity record. Three families of
difference, all of them in what the schema bundle carried rather than in the
introspection code, and all three closed additively.

The prose. `@idfkit/schemas` dropped `memo` and `note` by omission: slimType
and slimField never read them. It now emits them as a deduplicated pool to
data/docs.json.gz, 4,878 distinct strings standing in for roughly 119,000
occurrences, referenced by one integer per record and per property.

describeObjectType takes the pool as an optional third argument and stays
synchronous. Reading a file is not, and making the function async to fetch
something most callers do not want would be a breaking change serving the
minority. A caller who passes nothing gets exactly what they got before,
which is what keeps this additive, and the corpus runner is a caller that
opts in.

The accepted values. The blank is legal for 21,962 enum-bearing fields across
the versions and Python reports it. It stays filtered out of `e`, because `e`
is what validate() checks against and admitting it there would change what
validation accepts. `eb` carries it alongside and the description path puts it
back. `se`, the collapsed anyOf string branch, was already in the bundle and
already read by validation; the description path now reads it too, so Autosize
and Autocalculate are visible on both sides.

The field order. Three types in every one of the seventeen schemas carry a
positional list holding only the name, so the description fell back to the key
order of the property map, which the content-addressing serializer had sorted.
`fo` records the declaration order that sort destroys, for those three and
nothing else. Two of them actually diverged; the third agreed by alphabetical
luck and is fixed anyway, because a schema edit renaming either field would
otherwise break it in silence.

Proven against Python, not against our own assumptions: a digest over every
memo and note of all 858 types in 26.1.0 matches the one Python produces.

MEASURED COST, and it is not what the plan predicted. The plan expected the
prose to add 190,471 bytes and land the install at 86.2 percent of budget. It
adds 310,253 and lands at 92.9 percent, 1,705,108 bytes against 1,835,008,
with 126.9 KB of headroom. The pool itself is 175,035 bytes, near the
prediction. The references cost 135,218 rather than 18,652, because a memo
that differs between versions makes two previously byte-identical definitions
distinct: unique defs go from 2,570 to 2,735. The estimate did not account for
deduplication degrading. Measured by rebuilding without the references, `eb`
and `fo` together cost nothing at all, 626 bytes less than before.

The gate passes and the budget is not moved again to make it pass.
…elds

IdfParseError held a single diagnostic's line and typeName, so a caller who
wanted the rest had nowhere to look. Python's IDFParseError has always carried
the collection; this is the half of the recorded divergence that was real.

`.line` and `.typeName` still resolve to the first finding's values, so no
existing caller breaks. They are a convenience over diagnostics[0] rather than
a second source of truth.

ParseDiagnostic gains the location it lacked, a file path, a column and the
object name, so both languages carry the same kinds of location. typeName is
NOT renamed to match Python's obj_type: that is idiomatic casing, the naming
register records it as such, and spending a rename to make the register harder
to read is the wrong trade.

Both diagnostic types gain a code from the shared vocabulary, and every site
that reports one populates it. The corpus compares findings on
(code, line, typeName) and never on message text, so wording stays free to
improve without turning every improvement into a conformance failure.

typeName moves from ParseDiagnostic up to LexDiagnostic, because the lexer
knows it too: an unterminated object has read its type name before it runs out
of input, and the finding used to drop it. The corpus case for a truncated
object was failing on exactly that.
The other language has had output_type=compressed since it was written and this
writer had no compact path at all to extend: comments:false skips the padding
and the comment and still puts every field on its own line, which is a
different, coarser output.

Branches at both places compressed has to reach: the per-field line break and
the blank separator after each object. Type name and every value on one line,
comma-separated, terminated once, which is the shape the other language
produces.

Asking for compressed and comments together is not an error. Compressed has no
comments by definition, so the narrower request is unambiguous.

The two are still not byte-identical under compressed, and are not meant to be:
it removes comments, indentation and blank lines and does not touch float
rendering, which is one of the six pinned defaults. What it guarantees, and
what the corpus checks structurally, is that a document written under it
re-reads to the document it came from.

All six defaults are pinned by tests first, at the values THIS writer uses:
four-space indent, column 30, insertion order, schema-consulted floats, minor
words lowercase, no generator header. Five of the six differ from the other
language and none of them moves.

The docs snippet for the new explanation page lives here rather than in the
site, because the site vendors TypeScript snippets from a docs release.
… set

ParseDiagnostic gained column, filepath and objectName, and nothing filled any
of them: they were type declarations only, while the changelog and the naming
register both said both libraries now carry the same kinds of location.

parseIdf now sets objectName at both diagnostic sites, by the same best-effort
first-positional-value rule the Python parser uses.

column and filepath are still not filled, and the changelog now says so rather
than implying otherwise. A column needs the lexer to track one, and a filepath
needs the file-reading edge to attach it; both are real changes rather than
oversights, and claiming them was the defect.
…declared

ordering closes the last one-sided control (SC-007, US4 scenario 2). It takes
sorted or source rather than a boolean, matching the other language, and
defaults to source, which is insertion order and what this writer always did.
It composes with versionFirst: ordering decides the sequence, versionFirst
lifts Version out of it.

This also makes the corpus case honest. writer-option-ordering asked for
source, which was already this writer's only behaviour, so it passed here even
when the runner dropped the option entirely.

column and filepath were declared on the diagnostic types and set by nothing,
so the location parity FR-033 claims was a type declaration. The lexer now
tracks the start of each line and reports the column of the object's first
NON-BLANK character, which is what Python reports: its regex matches the type
name, so an object indented three spaces is column 4 on both sides. Verified
against the same input in both languages.

filepath is attached at the node edge, the one place that knows where the text
came from, on both the returning path and the raising one. parseIdf takes text
and cannot know, so it is not given a new option to carry.

RawObject gains an optional column, which is what the three lexer tests that
changed were asserting the absence of.
The level covering assertion 4, the eight new cases, and the three closures.
conformance.ts is regenerated from the pin rather than edited.
@samuelduchesne
samuelduchesne requested review from a team as code owners September 4, 2026 12:11
Fixes #36. The same defect and the same fix as idfkit#191; the two had to land
together, or the corpus would report whichever moved first as a new divergence.

A missing semicolon swallows the object below it and slides that object's type
name into a numeric field. The field count still fits, so nothing overflows and
no parser notices by counting.

Positioned at the offending FIELD, not the object. The lexer records one offset
per object rather than a line per field, and the reporting path rescans from it
counting separators, so the common path pays nothing for a position almost no
object needs.

NOT ROUTED THROUGH `report`, which throws under `strict`. That is right for a
finding leaving nothing to return, such as an unknown type, and wrong here: the
object is built and the document is complete. Routing it through `report` made
strict parses fail on files that used to load, which the conformance corpus
caught immediately on two cases.

Any numeric field accepts a sizing sentinel, whatever its own `se` branch says.
The schema EnergyPlus ships is narrower about which sentinel belongs where than
the engine it ships with, and reading each branch literally produced 3,775
findings across the 760 example files of one release, every one against a model
EnergyPlus accepts. Whether the sentinel is the documented one is a schema
question, and validateObject already answers it.

Measured on the same 8,581 real files as the Python side, both languages report
the same 41 findings in the same 27 files, and every one is a true positive.
…g EnergyPlus

The conformance job installed EnergyPlus 26.1, roughly a gigabyte, to get at
one release's example files, and read them with one schema. It needed neither.
The library parses against its own bundled schemas, so the only thing wanted
from a release is the files, and a blobless, shallow, sparse clone of
testfiles/*.idf is about 210 MB. Cached on the release tag, which is immutable,
so seventeen downloads happen once rather than per pull request.

That is what makes sweeping all seventeen versions affordable, and sweeping all
of them is what catches the two defects a single release cannot show: a parser
change safe on the newest schema and wrong on an older one, and a file whose
content belongs to one version while its Version object declares another.

Seventeen parallel jobs, fail-fast off, each held to the count measured for
that version so a regression of one is caught.

FOUND BY RUNNING THIS AGAINST THE PYTHON SWEEP. The two agreed on the file and
the field and disagreed on the line by one, on every finding that had a comment
between the separator and the value. `fieldLine` stopped at the `!` and
reported the line the previous value sits on. A field's comma is followed by
`!- Field Name` in essentially every real file, so this was wrong nearly
everywhere and right in the conformance case, whose input carries no comments.
Both languages now report line 318 for the same value in the same file.

`errors` is zero on every version, against three in the Python sweep. Those are
three files whose Version object sits where its detector does not look, which
this library reads without trouble: idfkit#185, now measured at exactly three
files out of 12,644.
The sweep job ran 'npm run build', which this repository does not define, so all
seventeen jobs failed identically before reading a single file. The build is
'typecheck', which is 'tsc --build'.

The sweep reads dist/ rather than the sources, deliberately: that is what an
npm consumer receives, and a sweep of 12,644 real files is worth pointing at
the artifact rather than at something a bundler would transform first.
The sweep called bundle.load(version) directly, which demands an exact match,
so every file declaring 9.0 was reported unreadable against a bundle carrying
9.0.1: 627 of 627 files on three versions. A defect in the sweep that looked
exactly like one in the library. It now calls schemaFor, which is the path
loadIdf takes and the one that resolves a declared version onto a bundled one.

9.2.0 is held at four findings where Python is held at three. The extra is
real: _1a-Long0.0.idf carries an un-migrated 'report variable dictionary;',
which this lexer reports as an unknown type and Python's pattern discards in
silence because it has no comma. idfkit#193. The two numbers agree again once
that is decided, and holding them apart until then is the point of measuring
each side separately.
_1a-Long0.0.idf exists at 8.9.0, 9.0.1, 9.1.0 and 9.2.0 and not after, so the
extra finding this lexer reports and Python's pattern discards appears on all
four. Only 9.2.0 was held at four; the other three failed for the same reason.

The schemaFor fix worked: unreadable is zero on every version now, against 627
of 627 before it.
_1a-Long0.0.idf carries an un-migrated 'report variable dictionary;' at the
four tags it exists in. That is a defect in the file rather than in either
library, and the two libraries disagree about it: one reports an unknown type,
the other's pattern discards it for having no comma. The disagreement is real
and is tracked as idfkit#193.

Carrying it in the sweep meant holding four versions at a count that said
nothing about this repository's own regressions, which is what the sweep is
for. It is excluded in the sparse pattern, so it never lands, with the reason
and the issue at the point of exclusion.

Every version is back to its true count, and the two libraries' matrices are
now identical version for version. That is the useful property: they sweep the
same files, so a number moving on one side alone is a divergence.
The exclusion added in the last commit never took effect. The cache key was
`ep-testfiles-v<version>`, the tag is immutable so the key never changed, and
the restored directory was the pre-exclusion one. The clone step was skipped
entirely and the sweep read the 665 files it was meant to stop reading.

The tag is immutable but the SELECTION is not, and the key described only the
first. That is the bug, and bumping the key by hand would have fixed this
instance and left the next one.

The selection now lives in .github/ep-testfiles.sparse and the key is a hash of
that file, so changing what is swept necessarily changes the key. The clone
step reads its patterns from the same file, so the cache and the checkout
cannot disagree about what should be present.

Verified from a fresh clone: 664 files, the excluded one absent.
@samuelduchesne
samuelduchesne merged commit ab9488d into main Sep 4, 2026
38 of 42 checks passed
@samuelduchesne
samuelduchesne deleted the 002-close-tier1-partials branch September 4, 2026 14:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant