Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/ep-testfiles.sparse
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Which EnergyPlus example files the multi-version sweep reads.
#
# This lives in a file, and the sweep's cache key is a hash OF this file, so that changing what is
# swept necessarily changes the key. Keying on the release tag alone was wrong: the tag is
# immutable but the SELECTION is not, so an exclusion added here was served a pre-exclusion cache
# and never took effect.
#
# One pattern per line, passed to `git sparse-checkout set --no-cone`. A leading `!` excludes.

testfiles/*.idf

# Excluded: carries an un-migrated `report variable dictionary;` at the four tags it exists in
# (8.9.0 through 9.2.0), gone from EnergyPlus by 26.1. A defect in that file rather than in either
# library, and the two disagree about it: one reports an unknown object type, the other's pattern
# discards it for having no comma. See idfkit#193, closed as not-an-issue. Sweeping it would hold
# four versions at a count that says nothing about this repository's own regressions.
!testfiles/_1a-Long0.0.idf
128 changes: 88 additions & 40 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -257,8 +257,56 @@ jobs:
run: npm run docs:build

conformance:
name: Round-trip EnergyPlus example files
# Read every EnergyPlus example file, for every version @idfkit/schemas bundles.
#
# This used to install 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 a hit is permanent and seventeen downloads happen once rather than per pull
# request.
#
# Sweeping every version 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, because field lists and
# extensible groups moved between releases. And a file whose content belongs to one version
# while its Version object declares another, which EnergyPlus ships several of.
#
# The counts are held per version and are compared against the Python library's, which sweeps
# the same files. A number that moves on one side and not the other is a divergence.
name: "E+ ${{ matrix.version }}"
runs-on: ubuntu-latest
strategy:
# Every version reports for itself. One release failing must not cancel the sixteen that
# would have told you whether the fault is that release or the change under test.
fail-fast: false
matrix:
# `findings` is that version's MEASURED count, not a round number, so a regression of one
# is caught. `errors` is zero: unlike the Python library, this one detects a Version object
# wherever it sits, so no example file fails to read at all.
#
# These numbers are IDENTICAL to the Python library's, version for version, and that is
# the point: the two sweep the same files and any number that moves on one side alone is a
# divergence. Three everywhere is the two parametric-preprocessor files; 22.2.0 adds nine
# objects absent from the schema its files declare, 25.2.0 and 24.1.0 one stale-stamp file
# each.
include:
- { version: "8.9.0", findings: 3, errors: 0 }
- { version: "9.0.1", findings: 3, errors: 0 }
- { version: "9.1.0", findings: 3, errors: 0 }
- { version: "9.2.0", findings: 3, errors: 0 }
- { version: "9.3.0", findings: 3, errors: 0 }
- { version: "9.4.0", findings: 3, errors: 0 }
- { version: "9.5.0", findings: 3, errors: 0 }
- { version: "9.6.0", findings: 3, errors: 0 }
- { version: "22.1.0", findings: 3, errors: 0 }
- { version: "22.2.0", findings: 13, errors: 0 }
- { version: "23.1.0", findings: 3, errors: 0 }
- { version: "23.2.0", findings: 3, errors: 0 }
- { version: "24.1.0", findings: 4, errors: 0 }
- { version: "24.2.0", findings: 3, errors: 0 }
- { version: "25.1.0", findings: 3, errors: 0 }
- { version: "25.2.0", findings: 8, errors: 0 }
- { version: "26.1.0", findings: 3, errors: 0 }
steps:
- uses: actions/checkout@v4

Expand All @@ -269,47 +317,47 @@ jobs:

- run: npm ci

- name: Install EnergyPlus
# The example set is the real conformance suite: 760 files covering
# extensible shapes, blank names, and autosized fields that hand-written
# fixtures never reach. Unit tests skip these when absent, so this job
# exists to make sure they actually run somewhere.
env:
GH_TOKEN: ${{ github.token }}
# The sweep reads `dist/`, which is what an npm consumer receives, rather than the sources a
# bundler would transform. `typecheck` is `tsc --build`, so this is the build.
- run: npm run typecheck

- name: Cache the example files
id: cache-examples
uses: actions/cache@v4
with:
path: ep/testfiles
# The hash of the sparse file is in the key, so changing what is swept changes the key.
# Keying on the tag alone served a stale selection when the exclusion below was added.
key: ep-testfiles-v${{ matrix.version }}-${{ hashFiles('.github/ep-testfiles.sparse') }}

- name: Fetch the example files for this release
if: steps.cache-examples.outputs.cache-hit != 'true'
run: |
set -euo pipefail
TAG=v26.1.0

# The asset name cannot be built from the version: it embeds a build
# hash (EnergyPlus-26.1.0-6f2e40d102-...) and names whichever Ubuntu
# that release targeted, which moves between releases. Ask the API.
ASSETS="$(gh api "repos/NREL/EnergyPlus/releases/tags/${TAG}" --jq '.assets[].name')"

# Prefer the build for the runner's own Ubuntu; a mismatched glibc
# fails at exec time, well after this step has reported success.
UBUNTU="$(. /etc/os-release && echo "$VERSION_ID")"
NAME="$(printf '%s\n' "$ASSETS" | grep -E "Linux-Ubuntu${UBUNTU}-x86_64\.tar\.gz$" || true)"
if [ -z "$NAME" ]; then
NAME="$(printf '%s\n' "$ASSETS" \
| grep -E 'Linux-Ubuntu[0-9.]+-x86_64\.tar\.gz$' | sort -V | tail -1 || true)"
echo "::warning::No ${TAG} build for Ubuntu ${UBUNTU}; falling back to ${NAME:-none}"
fi
if [ -z "$NAME" ]; then
echo "::error::No Linux x86_64 tarball on the ${TAG} release. Assets were:"
printf '%s\n' "$ASSETS"
exit 1
fi

echo "Installing $NAME"
curl -fsSL -o ep.tar.gz \
"https://github.com/NREL/EnergyPlus/releases/download/${TAG}/${NAME}"
mkdir -p "$HOME/EnergyPlus"
tar -xzf ep.tar.gz -C "$HOME/EnergyPlus" --strip-components=1
echo "ENERGYPLUS_DIR=$HOME/EnergyPlus" >> "$GITHUB_ENV"

- run: npm test
# NREL/EnergyPlus redirects here since the rename. The canonical name is used so that the
# redirect being dropped some day is a loud failure rather than a silent one.
REPO=https://github.com/NatLabRockies/EnergyPlus

# --filter=blob:none fetches no file contents until checkout and --depth 1 no history;
# the sparse pattern then pulls only the IDF files.
git clone --filter=blob:none --no-checkout --depth 1 \
--branch "v${{ matrix.version }}" "$REPO" ep
cd ep
# The selection lives in .github/ep-testfiles.sparse, which the cache key hashes, so the
# two cannot disagree about what should be present.
mapfile -t PATTERNS < <(grep -vE '^[[:space:]]*(#|$)' "$GITHUB_WORKSPACE/.github/ep-testfiles.sparse")
git sparse-checkout set --no-cone "${PATTERNS[@]}"
git checkout

- name: Fail if the example files were not found
run: |
test -d "$ENERGYPLUS_DIR/ExampleFiles" \
|| (echo "::error::ExampleFiles missing; conformance job proved nothing" && exit 1)
# An empty sweep passes every threshold while proving nothing.
count=$(find ep/testfiles -maxdepth 1 -name '*.idf' | wc -l)
echo "found $count example files for ${{ matrix.version }}"
test "$count" -gt 0 \
|| (echo "::error::no example files at v${{ matrix.version }}; the sweep proved nothing" && exit 1)

- name: Read every file
run: |
node scripts/sweep-example-files.mjs ep/testfiles \
--max-findings ${{ matrix.findings }} --max-errors ${{ matrix.errors }}
63 changes: 63 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,69 @@ The packages in this repository, `@idfkit/core`, `@idfkit/schemas`, and

## [Unreleased]

### Added

- `describeObjectType` reports the schema's explanatory prose. It takes an
optional third argument, the prose pool, and fills `memo` on a type
description and `note` on a field description from it.

The pool ships in the default install at `@idfkit/schemas`'s `data/`
directory, as `docs.json.gz`, and is read the same way the manifests and the
type store are. It is 4,878 distinct strings standing in for roughly 119,000
occurrences across the seventeen bundled schemas.

The signature stays synchronous and the pool is passed in rather than reached
for. Reading a file is not synchronous, 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**: `undefined` prose, everywhere.

The prose never reaches the model-reading path. It is a separate file under
`data/`, which the bundle-purity check already fences: an esbuild metafile
for a minimal read-and-write page contains zero inputs under any `data/`
directory, and that check now covers the pool with no change.

- `writeIdf` takes `compressed`, putting each object on one line with no
comments and no blank separators. The counterpart of the Python library's
`output_type="compressed"`, and it means the same thing.

`comments: false` is not this: it skips the padding and the comment and still
puts every field on its own line.

- `IdfParseError` carries `diagnostics`, every finding that stopped the parse,
rather than one finding flattened into two fields. `.line` and `.typeName`
still resolve to the first finding's values, so no existing caller breaks.

- `ParseDiagnostic` gains a `code` and an `objectName`, and declares a `column`
and a `filepath` so both libraries carry the same kinds of location. `code` is
one of eight values shared with the Python library. Match on it rather than
on `message`: the corpus compares findings on `(code, line, typeName)` and
never on wording.

`column` and `filepath` are declared but not yet filled: the lexer counts
lines and not columns, and `parseIdf` takes text rather than a path, so
neither value exists at the point a finding is built. They are optional, so a
reader must treat them as absent until the lexer tracks a column and the
file-reading edge attaches the path it read from.

### Fixed

- `enumValues` reports the values it was omitting. The empty string is included
for the enums that declare one, and the sentinels `Autosize` and
`Autocalculate` are read from the collapsed `anyOf` string branch, which
validation has always read and this path never did.

Validation is unaffected: the blank is still filtered out of the list
validation checks against and is restored only in the description.

- Three object types reported their fields in alphabetical order rather than
declaration order: `ZoneProperty:UserViewFactors:BySurfaceName`,
`ZoneTerminalUnitList`, and `SolarCollector:UnglazedTranspired:Multisystem`.
These are the three whose positional field list holds only the name, so the
description fell back to the key order of the property map, which the
content-addressing serializer had sorted. The bundle now records their
declaration order explicitly.

### Changed

- The install-size budget for the shared name rose from 1.5 MB to 1.75 MB
Expand Down
20 changes: 20 additions & 0 deletions docs-snippets/explanation/two-writers-one-model/controls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
// Preamble, not shown on the page: the values this example assumes it already
// has, each with the type the page's earlier steps would have given it.
import { writeIdf, type IdfDocument } from '@idfkit/core';
declare const model: IdfDocument;

// --8<-- [start:controls]
// Every control, at a value that is not the default.
const text = writeIdf(model, {
indent: ' ',
commentColumn: 45,
comments: true,
});
// --8<-- [end:controls]

// --8<-- [start:compressed]
const compact = writeIdf(model, { compressed: true });
// --8<-- [end:compressed]

void text;
void compact;
4 changes: 2 additions & 2 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@
"node": ">=20"
},
"idfkit": {
"conformance": "conformance-2026.7",
"governance": "governance-2026.9"
"conformance": "conformance-2026.8",
"governance": "governance-2026.10"
},
"dependencies": {
"@idfkit/schemas": "0.0.0"
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@
* This is not a version number and it is not compared to one. Two installed libraries agree on the
* formats when they declare the same level, whatever their own versions say (FR-025).
*/
export const CONFORMANCE_LEVEL = 'conformance-2026.7';
export const CONFORMANCE_LEVEL = 'conformance-2026.8';
Loading
Loading