Answer where the cursor is, without reparsing the file - #40
Merged
Conversation
Adds a syntax layer to @idfkit/core and a language service in a new opt-in package, @idfkit/language. Both are synchronous and free of input and output, so the same code runs in Node, a browser, a browser worker and behind an editor server. THE OBSERVATION THE DESIGN TURNS ON IDF is a flat sequence of statements terminated by a semicolon, with no nesting, no string literals and no escapes, and a comment runs from an exclamation mark to the end of its line. So the statement containing an offset is found by scanning backwards to the nearest semicolon not inside a comment, at a cost proportional to the statement rather than to the file. That is what lets a cursor be answered with no reparse, no incremental parser, no cache and therefore no hidden state. Two cost classes rather than one. Classifying the text and positioning findings are whole-file work, run when the document settles. Asking what completes here, what this means and what this points at are bounded local work, run on a keystroke. The second class never touches the first, and contextAt does not import scanIdf. MEASURED, NOT HOPED. bench/corpus.mjs generates 10,001 statements and 40,002 lines from a fixed seed, and bench/budget.mjs gates on ratios measured inside one run rather than on wall-clock milliseconds, because a runner varies by more than the margin being defended and a gate that fails randomly is a gate somebody disables. contextAt p95 / parseIdf median 0.030% against 2% completionsAt p95 / parseIdf median 0.033% against 2% scanIdf median / parseIdf median 0.48x against 1.25x contextAt, 641,576 vs 6,860 bytes 1.66x against 3x The last is the one that pins the design. A ratio against parseIdf alone could be satisfied by a merely fast reparse; independence from file size could not. Demonstrated to fail on purpose by making contextAt call scanIdf. ONE SCANNER, TWO MODES. The character rules lived in two near-copies, in lex and in the internal fieldLine helper. Both are now callers of parse/scan.ts, which stays internal. If the layer and the reader disagreed by one character about where a comment ends, findings would land on the wrong field, and that failure is invisible until a file puts a comment somewhere unusual. A corpus test asserts the fields scanIdf reports are positionally identical to the values lex reports. The tiling invariant is what the corpus test checks, not byte-identical reconstruction: the layer holds the source text, so reconstruction returns the text by construction and a test of it proves nothing. A failure names which of the six clauses broke and at which token index. No token yielded by classify crosses a line boundary, because no editor token encoding can express one that does. A stored value region can, since the format lets a field be written across two lines and real files do it; classify splits at the newline and moves no boundary. NOT MODIFIED: validateDocument, parseIdf, IdfDocument. Correlation happens afterwards over the syntax layer, so a caller who never asks for a position receives exactly what it received before. Snapshots over the corpus assert that, which is what keeps positions additive rather than merely intended. Two facts correlation rests on are asserted in their own tests rather than trusted: addRaw throws on a duplicate name so a parsed document never holds two objects sharing a folded type and name, and IdfCollection preserves insertion order. WHY A SIXTH PACKAGE, and it is arithmetic rather than taste. Everything in @idfkit/core is installed by everyone who runs npm install idfkit, so shipping the service there charges every model-reading install for an editor they do not have. It is an optional peer reached through idfkit/language, on the mechanism @idfkit/weather already established: a dynamic import caught behind a top-level await, because a static export * links before any local code runs and the guard would never execute. The hand-written re-export list is held against the package's real surface by check-facade.mjs. MEASURED COST, and it is not what the plan predicted. The plan expected the layer to emit roughly 30 KB. It adds 61,493 bytes, taking the install from 1,734,364 to 1,795,857 against a budget of 1,835,008. The gate passes with no amendment, which is what SC-015 asked, but headroom falls from 98.3 KB to 38.2 KB. The budget is not moved to make it pass, and the next addition to core will have this conversation rather than this one. Degraded answers are distinguishable from empty ones. An editor that renders "no suggestions" identically for "this field accepts free text" and "I have no schema for EnergyPlus 26.1" teaches the reader that the tool is broken in the first case and silently wrong in the second, so each is its own result. Every offer carries the region it replaces and every explanation the region it describes. An editor's own word rules break on this format in both directions, since type names contain colons and values contain spaces, so a consumer left to derive the span would get it wrong on the majority of real completions. The governance pin moves to governance-2026.11, which carries the register and ledger entries. conformance-2026.8 does not move: a capability that exists in one language asserts no cross-language agreement, so there is nothing for the corpus to compare and no case is added.
`insideComment` asked `text.lastIndexOf('!', index)`, which reads back to the
start of the file whenever nothing before the cursor is an exclamation mark.
That is every position in a machine-exported file, which carries no comments at
all, so the cost of every answer grew with how far into the file the cursor sat.
It is the one property this module exists to guarantee it does not have.
Measured between the two ends of an 800 KB comment-free file: 8,385x before,
flat after. The two implementations agree at all 115,559 offsets tested, so
this changes cost and nothing else. The search now runs forward from the start
of the cursor's own line, which answers the same question and cannot leave the
line.
WHY NOTHING CAUGHT IT, which is the more important half. bench/corpus.mjs
writes `!- Field Name` on very nearly every line, because that is what a
human-edited file looks like. Under that shape the backward search always
terminates within a line, so the unbounded form and the bounded one measure
alike. The file-size gate could not see it either, and not by accident: it
compares the same offset in two files, so a cost that grows with the OFFSET
rather than with the size divides out of it. Both readings sit at the same
place in their own file.
So the gate gains an axis rather than a threshold. `commentFreeModel()` is the
reference model with its comments stripped, and the new gate measures one
answer at its first statement against the same answer at its last. Same file,
so size cannot explain a breach; no comments, so the defect has nowhere to
hide. Held at 3x, measuring about 1.4 today, and demonstrated to fail on the
defect at 925x while all six existing gates stayed green.
ALSO FIXED, from the same review:
`nameKey` joined on `\\u0000` inside a template literal, which is a backslash
followed by u0000: the six printable characters, which a name may perfectly
well contain. The separator is now a real NUL behind a named constant, so the
comment claiming a name cannot contain it is true again. Two objects whose
names bracketed that literal would have shared a key, and a finding about one
would have underlined the other with nothing failing.
publish.yml never learned about the new package. It was absent from the publish
loop, its own core peer placeholder was never rewritten, and the facade's
`peerDependencies.@idfkit/language` stayed at 0.0.0, so a release would have
published nothing under that name and shipped a facade asking for a version
that has never existed. The peer is rewritten to a caret rather than to the
exact range contracts/language-service.md asks for, and the reason is written
where the rewrite is: the facade depends on core with a caret, so an exact peer
becomes unsatisfiable at core's first patch release. Tightening it means
pinning the facade too, which is a decision about the repository rather than
about this package.
scripts/lib/clean-install.mjs redirected every scoped name the facade can ask
for to a local tarball except the new one, so every distribution gate was
resolving @idfkit/language from the real registry. It passes today only because
npm skips an unresolvable optional peer in silence; under --offline or
--strict-peer-deps the gates would exit 2, and their evidence about an absent
component was unfounded until this was true.
.gitattributes marks the syntax fixtures `-text`. Three of them differ from one
another only in their line endings and the matrix runs windows-latest, where
git's default core.autocrlf rewrites LF on checkout. The three would have
arrived as one file and the tests written for the distinction would have kept
passing while measuring nothing.
check-facade.mjs still said four subpaths and "a fifth subpath" after ./language
made it five, so a real breach would have reported the wrong number at the
reader. It also read every export target twice.
contracts/language-service.md asks for an exact peer and the release workflow was rewriting a caret. The contract's reasoning is the whole argument for splitting the two packages: the service reads the syntax layer core builds, so a pair that disagrees about that layer's shape puts findings on the wrong characters SILENTLY. Nothing throws, no test fails, the underline is simply in the wrong place. A caret permits exactly that pairing across a core patch. This does not contradict the facade's caret on core, which contracts/ distribution.md requires so a patch of core reaches a consumer without a facade release. npm resolves peers while building the tree, so idfkit's "^X.Y.Z" and this "X.Y.Z" are satisfied together by X.Y.Z. The narrow case where they cannot be is one where another dependency has already forced core above the version the service was built against, and refusing to resolve is the right answer there: that is the pairing the exact range exists to reject. The prerelease job is untouched. It publishes the facade alone, so no peer of the language package is in scope there.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds a syntax layer to
@idfkit/coreand a language service in a new opt-in package,@idfkit/language. Both are synchronous and free of input and output, so the same code runs in Node, a browser, a browser worker and behind an editor server.The observation the design turns on
IDF is a flat sequence of statements terminated by a semicolon, with no nesting, no string literals and no escapes, and a comment runs from an exclamation mark to the end of its line. So the statement containing an offset is found by scanning backwards to the nearest semicolon not inside a comment, at a cost proportional to the statement rather than to the file. That is what lets a cursor be answered with no reparse, no incremental parser, no cache and therefore no hidden state.
Two cost classes rather than one. Classifying the text and positioning findings are whole-file work, run when the document settles. Asking what completes here, what this means and what this points at are bounded local work, run on a keystroke.
contextAtdoes not importscanIdf.Measured, not hoped
bench/corpus.mjsgenerates 10,001 statements and 40,002 lines from a fixed seed.bench/budget.mjsgates on ratios measured inside one run rather than on wall-clock milliseconds, because a runner varies by more than the margin being defended and a gate that fails randomly is a gate somebody disables.contextAtp95 /parseIdfmediancompletionsAtp95 /parseIdfmedianscanIdfmedian /parseIdfmedianparseIdfmedian /lexmediancontextAt, 641,576 vs 6,860 bytescontextAt, last vs first statement, no commentsThe last two pin the design, on different axes. A ratio against
parseIdfcould be met by a merely fast reparse; independence from file size could not. But size independence compares two files and cannot see a cost that grows with the offset, because both readings sit at the same place in their own file.That second axis is here because it caught a real defect, not a hypothetical one.
insideCommentsearched backwards for an exclamation mark, which reads the whole prefix in a file that holds none, so every answer's cost grew with how far into the file the cursor sat. Measured at 8,385x between the two ends of an 800 KB comment-free file. The reference model could not show it, because a comment on nearly every line stops that search within one line, and all six other gates stayed green throughout. Both gates are demonstrated to fail on purpose.One scanner, two modes
The character rules lived in two near-copies, in
lexand in the internalfieldLinehelper. Both are now callers ofparse/scan.ts, which stays internal. If the layer and the reader disagreed by one character about where a comment ends, findings would land on the wrong field, and that failure is invisible until a file puts a comment somewhere unusual. A corpus test asserts the fieldsscanIdfreports are positionally identical to the valueslexreports.The tiling invariant is what the corpus test checks, not byte-identical reconstruction: the layer holds the source text, so reconstruction returns the text by construction and a test of it proves nothing. A failure names which of the six clauses broke and at which token index.
No token yielded by
classifycrosses a line boundary, because no editor token encoding can express one that does. A storedvalueregion can, since the format lets a field be written across two lines and real files do it;classifysplits at the newline and moves no boundary.Not modified
validateDocument,parseIdfandIdfDocument. Correlation happens afterwards over the syntax layer, so a caller who never asks for a position receives exactly what it received before. Committed snapshots over the corpus assert that, which is what keeps positions additive rather than merely intended.Two facts correlation rests on are asserted in their own tests rather than trusted:
addRawthrows on a duplicate name so a parsed document never holds two objects sharing a folded type and name, andIdfCollectionpreserves insertion order.Why a sixth package
Arithmetic rather than taste. Everything in
@idfkit/coreis installed by everyone who runsnpm install idfkit, so shipping the service there charges every model-reading install for an editor they do not have. It is an optional peer reached asidfkit/language, on the mechanism@idfkit/weatheralready established: a dynamic import caught behind a top-levelawait, because a staticexport *links before any local code runs and the guard would never execute.Measured cost, and it is not what the plan predicted. The plan expected the layer to emit roughly 30 KB. It adds 61,493 bytes, taking the install from 1,734,364 to 1,795,857 against a budget of 1,835,008. The gate passes with no amendment, which is what SC-015 asked, but headroom falls from 98.3 KB to 38.2 KB. The budget is not moved to make it pass, and the next addition to core will have this conversation rather than this one.
Review fixes carried in the second commit
nameKeyjoined on a NUL written as an escaped backslash inside a template literal, so the separator was six printable characters that a name may perfectly well contain. Two objects bracketing that literal would have shared a key, and a finding about one would have underlined the other, silently.publish.ymlnever learned about the new package: absent from the publish loop, its core peer placeholder never rewritten, and the facade'speerDependencies.@idfkit/languageleft at0.0.0. A release would have published nothing under that name and shipped a facade asking for a version that has never existed.clean-install.mjsredirected every scoped name to a local tarball except the new one, so every distribution gate resolved@idfkit/languagefrom the real registry. It passed only because npm skips an unresolvable optional peer in silence; under--offlineor--strict-peer-depsthe gates would exit 2..gitattributesmarks the syntax fixtures-text. Three differ only in line endings and the matrix runswindows-latest, wherecore.autocrlfwould have collapsed them into one while the tests kept passing against text no longer containing the case they were written for.check-facade.mjsstill said four subpaths after./languagemade it five, so a real breach would have reported the wrong number.One open decision for the reviewer
publish.ymlrewrites@idfkit/language's core peer to a caret, wherecontracts/language-service.mdasks for an exact range. The contract's reasoning is right and the range does not deliver it: the facade depends on core with a caret, so core's first patch release makes an exact peer unsatisfiable for anyone installing the facade. Exact buys no safety there and breaks every install one release later. Tightening it means pinning the facade's own core dependency in the same change, which is a decision about the repository rather than about this package. The reasoning is recorded where the rewrite happens.Gates
typecheck,test(699 tests, 31 files),format:check, all 11check:*scripts, and 8 budget gates are green locally.check:publicationrefuses, on a precondition that predates this branch: the conformance pin isconformance-2026.8while feature 004's FR-044 namesconformance-2026.7as the level both libraries publish at. All six distribution preconditions pass. That question is feature 004's to settle and gates the shared name rather than this feature.