HF-307 PR 4/4: describe only the functions the license grants - #1731
marcin-kordas-hoc wants to merge 16 commits into
Conversation
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
hyperformula-docs | cd79b06 | Commit Preview URL Branch Preview URL |
Aug 26 2026, 03:39 AM |
Performance comparison of head (cd79b06) vs base (8dcca66) |
199f4f1 to
cd56f70
Compare
Ports the read side of the typed license key format into src/license/vendor/ as TypeScript (allowJs is off and strict is on, so this is a port rather than a copy). Nothing consumes it yet - the key-to-entitlement adapter follows in the next commit. Vendored: constants, the default schema, the six reader-side helpers of utils, the pure-JS SHA-512, and the key-data extractor. Not vendored: key generation and the schema validator, which are unreachable here because HyperFormula only ever reads keys and always reads them with the default schema. The delivery form follows the key spec's own recommendation of a vendored copy with a drift check, rather than a shared package: a private dependency would break npm install for open-source users of this GPL package. PROVENANCE.md records the upstream commit and a per-file sha256 of the upstream sources, so drift is detectable by re-cloning and re-hashing, and lists the deliberate divergences - notably that the extractor drops the custom-schema parameter and additionally returns licensedProductName, since the grace period lives on the licensed product entry and re-deriving "the first schema product present in the payload" in the caller could drift from the rule used to derive the expiry. Payload fields are typed unknown: field types are checked when a key is generated, which constrains nothing about a payload that reaches this code, so consumers must narrow rather than trust a declared shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
Before this commit a genuine typed key did not work at all. The validity check recognizes three fixed strings and the older 25-character format; a typed key matched none of them and fell through to INVALID, so every formula returned #LIC! and the console warned that a paid-for key was invalid. Verified by building an engine with a real, unexpired subscription key before touching anything. resolveLicense reads the key once and answers both gates from that single reading, so they cannot disagree about what the string says. A typed key is recognized first; anything else - gpl-v3, an older-format key, an empty string, a typed key with a broken checksum - falls through to checkLicenseKeyValidity completely untouched. That is what keeps existing behaviour bit-identical: the existing function is not modified, only extracted from (notifyLicenseKeyState), so both paths report the same states with the same wording and share the one-warning-per-page flag rather than each getting their own. Expiry follows the format's own rules: a key with no expiration date never expires; trial and subscription keep working for `grace` days past an inclusive expiration date, against the clock; a perpetual key compares its maintenance end against the build release date, so an air-gapped install with a wrong clock is unaffected. An unknown release date resolves to "not expired", matching what the existing validator already does - a build that cannot tell its own age must not start rejecting keys customers paid for. The invariant this PR must not break is enforced here and mutation-tested: only a VALID typed key resolves to a restricted entitlement. Missing, invalid and expired all resolve to unrestrictedEntitlement(), for typed keys exactly as for the older format. Gate A already stops formula evaluation on its own; letting a bad key restrict the entitlement as well would make PR 2's ensureCapability throw from the CRUD API, turning today's "formulas fail, the API still works" into a silent breaking change for every user whose key lapsed. Full unit suite green (6260 tests). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
Replaces the single-core-token placeholder with the four function packages of the packaging design, and teaches the adapter both payload shapes. The membership is transcribed from that design's own per-function evidence file rather than invented. The transcript was checked by reproducing the file's five published counts exactly: 370 rows, and 17 / 51 / 127 / 355 cumulative plus 15 operators. Coverage was checked the other way too - all 423 registered function ids resolve into the 370 canonical entries once HF's 53 declared aliases are canonicalised, with zero uncovered, which is also what makes the rule that aliases travel with their canonical function true here for free. THIS MEMBERSHIP IS A DRAFT and is marked as such in the source. The packaging design is still under review, with the free tier's exact contents and the placement of several function families not yet settled. Landing it now is a deliberate call, not a claim that it is final. capability-table.spec.ts pins the counts so a later edit cannot drift from the evidence silently. Both payload shapes are read, per product entry, by detecting `capabilities`: the shipped shape (tier/addons/exp/grace, contract type from the key tag) and the newer specified shape (capabilities/usage_until/release_until/notice/flags, with no commercial vocabulary in the payload). The two disagree about nearly every field, the newer one is still under review, and only the first can be minted today, so reading both means an already-issued key keeps working whichever way that is settled. The newer spec also contradicts itself on whether its dates are YYYY-MM-DD strings or numeric timestamps, so both are accepted. Commercial tier names are translated to capability tokens in the adapter, not mirrored into the table, so the table speaks one vocabulary. An unknown tier passes through untranslated and surfaces as an unrecognized capability rather than being swallowed. Grants are stored fully expanded rather than chained through `implies`: the design states the enforcement layer must not assume a hierarchy between tokens. Operators are granted by the core token as engine baseline, and the protected built-ins OFFSET and VERSION are listed nowhere, since the interpreter never gate-checks them. Features are all still granted by the core token. The evidence covers functions only; nothing has decided whether undo/redo or the clipboard is a paid feature, and restricting one here would both invent a product decision and make PR 2's ensureCapability start throwing from the CRUD API for real keys. Full unit suite green (6272 tests). Table membership mutation-tested: moving one function across a package boundary fails the gating tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
…l-open dates Bugbot and a code-review pass found real defects in the previous three commits. Each was reproduced before fixing and is now pinned by a test. TWO CRASHES on checksum-valid keys. A key whose HyperFormula entry was `null` rather than an object threw "Cannot read properties of null (reading 'tier')" straight out of the Config constructor, and a numeric date outside Date's range threw "Invalid time value" from toISOString. Both killed engine construction, where every other malformed key merely resolves to INVALID. Every payload field is untrusted; nothing may assume a shape now. CUSTOM FUNCTIONS WERE GATED. `functions_4` was filled from the function registry at run time, which swept in anything registered through registerFunctionPlugin - putting a user's OWN function into the most expensive package and returning #LIC! for it on every smaller licence, the opposite of decision D1. The excel-simulator set is now enumerated statically like the other three, so the whole table is static and a function it does not list is not gated at all, which is exactly the treatment a custom function should get. The cost is that a newly implemented built-in is ungated until added here, which the completeness invariant fails on - a much better failure mode. FAIL-OPEN DATES. An unreadable rev-5 date resolved to "never expires", turning a minting typo into a permanent licence, while the shipped shape already rejects a malformed `exp`. A present-but-unreadable date now invalidates the key. String dates go through the vendored parseIsoDate, so `2027-02-30` is rejected rather than rolling over into March and granting two extra days. WRONG SOURCE FOR REV-5 TERMS. Dates, notice and grace were read from the licensed product entry for both shapes, but that rule belongs to the shipped shape; under rev 5 every product entry carries its own terms. HyperFormula now reads its own under rev 5, and flags no longer disagree with the rest. Also: the expired-on date now reports the first day NOT covered, the convention the legacy validator already uses, so the two paths no longer differ by a day. Corrected a comment that the capability-table commit had invalidated: the core token grants operators and the API surface, NOT a usable function set, so a key whose tokens this build does not recognize evaluates operators only and returns #LIC! for every function, silently. That cliff is deliberate per D3 but severe; it is now described accurately and flagged for review rather than misdescribed. Two review findings were checked and rejected: the package arrays total 16/50/125 rather than the documented 17/51/127 because OFFSET and VERSION are protected and deliberately excluded, and INT is an excel-simulator function in the evidence, not a math-engine one. Full unit suite green (6305 tests). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
formatDate used local getters on a date built at UTC midnight, so anyone west of UTC saw a console warning naming the day BEFORE the one their key carries. This is pre-existing rather than new - the legacy path builds its date the same way, from a whole number of days since the epoch - so fixing the shared helper corrects both paths rather than leaving two conventions. No test asserts the message text, and nothing else calls formatDate. Verified by running the same expired key under TZ=Pacific/Midway (UTC-11), TZ=UTC and TZ=Pacific/Kiritimati (UTC+14): all three now print "January 2, 2020" for a key whose exp is 2020-01-01, which is the first day NOT covered - the convention the legacy path already used. Deliberately not covered by a test: the only observable is console.warn, and the warn-once flag is module-level and never reset, so such a test would fire only when it happened to run first in the module registry. An order-dependent test is worse than none here. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
…swers 12.08
Two changes from Kuba's answers on the task (comment of 12.08):
"Feature gating should work, but the legacy keys should grant all feat:*
capabilities." An earlier revision granted all five features from CORE_TOKEN,
which made feature gating inert by construction - no typed key could ever lose
an API area. The five features now live on their own feat:* tokens (spelled
after the task's draft vocabulary), and core grants the operators alone. A
rev-5 key states its feature grants explicitly; the shipped shape - whose
vocabulary predates feature tokens and whose tiers are products sold with the
full API - is granted all five by the adapter, so an existing shipped-shape
key's API behaviour is unchanged. Legacy keys resolve to the unrestricted
entitlement, which is the carve-out Kuba named, already in place.
"One unrecognized token currently silences the ENTIRE key - this seems like an
implementation error." Confirmed and decoupled: silence now comes solely from
the key's flags. The coupling suppressed strictly more than D3 asks for - a
vocabulary mismatch would have swallowed expiry notices too.
The #LIC! cliff comment is updated to record D6-A: Kuba ratified D3 as-is
("this situation should never happen. There is no point in issuing a key if
empty capabilities.").
Tests: handsontable/hyperformula-tests#32
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
…honoured
Three fixes, all from reading key spec rev 5 (CU doc 8cnjcyf-31675 page
8cnjcyf-48155, updated 12.08) against the code and running the result.
**Feature tokens are OPT-IN, not opt-out.** The previous revision granted the
five feature areas only in the shipped-shape branch, so a key was denied every
gated API area unless it explicitly named `feat:*` tokens. Two key classes that
myHOT can mint TODAY do exactly that:
- rev-5 keys. §2.2 lists HyperFormula's whole token vocabulary as `functions_1..4`,
`spreadsheet`, `import_export` - there is NO `feat:*` entry at all. Minting the
spec's own §2 example payload and running it: setCellContents, addRows, copy,
undo, addNamedExpression and batch ALL threw.
- shipped-shape keys whose payload carries no usable `hyperformula` entry, i.e.
Handsontable-only keys and keys with `hyperformula: null`. These fell outside
the branch that did the granting, so they lost the API that `core` used to give
them - and, being gate-A VALID, they lost it without even a console warning.
So absence of a `feat:*` token cannot mean "no features": no vocabulary in
circulation can express one. It means "this key does not talk about features",
and the task's additive-safety rule - a grant may grow, never shrink - makes the
whole gated API the only safe reading. A key that DOES name a `feat:*` token
still gets exactly the areas it names, which is what Kuba asked for ("Feature
gating should work").
**`no-console-warns` is honoured.** rev 5 is not self-consistent about the flag:
its normative table and example payload (§2.3, §2) say `no-console-warns`, its
runtime sections (§4.3, §5.2) say `silent-console`, earlier revisions said plain
`silent`. Only the last two were recognised, so a doc-conformant SaaS key printed
console warnings it had explicitly asked to suppress. All three now count.
**An unreadable `capabilities` rejects the key.** `capabilities` present but not
an array fell through to the shipped-shape branch, which was a free pass twice
over: the key gained every feature it never carried, and its rev-5 dates were
never read, so a subscription expired in 2020 resolved as perpetual. It now
returns null (INVALID), matching what the module already does for an unreadable
date and what its own doc comment promises.
Also adds `resetLicenseKeyNotificationForTests` (@internal): the warn-once flag
is module-level and never reset, which made the whole console-message path
untestable - deleting the notify call left all 6300 tests green.
Tests: handsontable/hyperformula-tests#32
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
`TIER_TO_CAPABILITY_TOKEN` was an object literal, and the tier it is looked up
by comes from the payload - which is attacker-influenced, since a typed key's
checksum is an unkeyed SHA-512 that anyone can compute. An object lookup also
answers for every `Object.prototype` member, so `tier: "constructor"` resolved
to a FUNCTION and `tier: "__proto__"` to an object. Either one landed in the
capability token list, and the `feat:` scan added on 13.08 then called
`.indexOf` on it:
TypeError: token.indexOf is not a function
at licenseResolution.ts (Array.some) -> licenseTermsOf -> resolveLicense
-> new Config -> HyperFormula.buildFromArray
The engine failed to CONSTRUCT. That breaks the rule this module documents and
already honours elsewhere: a malformed key produces an `invalid` verdict, never
a thrown exception. Worth being precise about the history - the unsafe lookup
predates the 13.08 change, but before it a non-string token was merely ignored
by a Map lookup; the opt-in scan is what turned it into a crash.
Fixed by making the map a `Map`, which answers only for keys actually put in it
and matches `CAPABILITY_TABLE`. It also makes the types honest: `Record<string,
string>` told TypeScript the lookup yields a string, which was the lie behind
the crash, while `Map.get` returns `string | undefined`.
Behaviour for such a key is now identical to any other unknown tier: VALID key,
token passed through, recorded as unrecognized, grants nothing (D3).
Tests: handsontable/hyperformula-tests#32
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
… not hold
`releaseDateTimestamp` said it reads HT_RELEASE_DATE "so a perpetual typed key
and a legacy key agree on what 'this build' means". They do not, east of UTC.
This function uses `Date.UTC`; the legacy validator parses the same env value
with `new Date(month/day/year)`, which is LOCAL. Measured at process level:
HT_RELEASE_DATE=10/08/2026 legacy (local) typed (UTC)
TZ=UTC, TZ=America/Los_Angeles 20675 20675 agree
TZ=Asia/Tokyo 20674 20675 differ
TZ=Pacific/Kiritimati 20674 20675 differ
Raised by Bugbot on 12.08 and left unanswered for four days while I reported the
PR as review-clean off the check status - which it was not.
The CODE is right and stays. UTC is required for a typed key: key spec rev 5 §1.2
makes offline/online parity a hard rule, and a local clock breaks it. Legacy keeps
its local parse because legacy behaviour is frozen this release - switching it
would move the expiry verdict of already-issued keys by a day for every customer
east of UTC. So the fix is to stop the comment claiming the opposite, and to state
the consequence plainly: two customers east of UTC, one on a legacy key and one on
an equivalent typed key, can disagree by a day about whether this build is covered.
Reconciling them is a product decision.
No test accompanies this, deliberately. The property is not observable in this
suite: assigning `process.env.TZ` mid-run has no effect once the runtime resolved
its timezone (probed - UTC, Asia/Tokyo and Pacific/Kiritimati all returned an
identical timestamp inside Jest), and CI runs in UTC where both parses agree. A
test written that way passes whichever parse the source uses; I wrote one,
mutation-checked it, found it vacuous, and removed it rather than ship an assertion
that cannot fail. Pinning it needs a timezone-parameterised CI job. The reasoning
sits next to the release-axis tests so the gap stays deliberate.
Tests: handsontable/hyperformula-tests#32
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
05882e1 to
ab1bba1
Compare
5d67b5d to
46ac32d
Compare
Tobiadefami
left a comment
There was a problem hiding this comment.
Reviewed at e9863f2 together with the paired tests at 903aabae. The shared gate-B predicate, alias canonicalization, list/details filtering, protected and custom-function behavior, capability-table membership, documentation, changelog, and current automated findings were checked. Six focused paired suites pass (244 tests), and the current engine checks are green. I found one material documentation inconsistency: the bad-key text says every function call returns #LIC! even though protected OFFSET and VERSION remain callable, as noted inline.
…grace `validityOf` computes the real deadline as `usage_until + 1 day + grace`, then reported `usage_until + 1 day` as the day the key expired, dropping the grace interval entirely. The comment two lines above already promised "the first day NOT covered", which is the convention the legacy validator uses, so the code disagreed with its own contract for every key carrying a positive grace. Measured on the stack head before the change: a key with usage_until 2027-08-12 and grace 90 stops working on 2027-11-11, and the console said "expired on August 13, 2027" - byte-identical to the message the same key produces with grace 0. After the change the two cases print November 11 and August 13 respectively. No validity decision reads `expiredOn`; its only consumer is the console message, so this changes what we tell the customer, not what the gate allows. Reported by Tobiadefami on this PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The licence comments carried two things this repository has no precedent for: the first name of a colleague attached to business decisions, and the ids of internal planning documents. Neither is resolvable by anyone reading the published package, and both were about to enter the public history permanently - a squash merge puts each branch's final content on develop, so removing them further up the stack would not have helped. The substance is untouched: every comment still says what was decided and why, and the HF-nnn task references stay, since those already appear in this repository. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`getAvailableFunctions` and `getFunctionDetails` read straight from the function registry, with no license filter, while the interpreter gates the same functions per call. A restricted key therefore advertised functions that return `#LIC!` when called - the exact failure removing the static metadata methods (HF-349) was meant to prevent, left half-delivered because the instance methods never learned to read the key their rationale said they could. Both now filter through `licenseListsFunction`, which shares one `licenseAllowsFunction` rule with the interpreter rather than spelling the same condition out twice, and canonicalises aliases the same way. Extracting that rule is the point: two copies would drift, and the drift is invisible until a customer's picker offers a function that fails. Gate B only, deliberately - never the validity state. A missing, invalid or expired key resolves to an unrestricted entitlement (the invariant), so it reaches the filter with `unrestricted` set and keeps the whole catalogue. That falls out of the invariant rather than being a second decision, and it is the useful answer: narrowing to the two protected built-ins would hand an integrator who has not wired up their key yet an empty function picker and no clue why. The list narrows only for a *valid* key that genuinely excludes a function. Also documents `#LIC!` in types-of-errors.md, which listed only key problems and not "function not in your package", and adds the CHANGELOG entry the feature has not carried so far - PRs 1-3 were internals by design. The guide deliberately documents the mechanism, not the package contents: HF-306 is still in review with six open questions, so publishing the lists now would put moving targets in the public docs. Tests: handsontable/hyperformula-tests#33 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
Re-derived every function's lowest package straight from the 21 fun:<family>.<A|B|C> group tokens in CU doc 8cnjcyf-33175/8cnjcyf-47835 and re-partitioned MATH_ENGINE_FUNCTIONS, CALCULATED_FIELDS_FUNCTIONS, SPREADSHEET_FUNCTIONS and EXCEL_SIMULATOR_FUNCTIONS to match. No function was added or removed (353 total, before and after) - only reassigned to its correct tier. The prior table was materially stale: missing 6/22/50 functions at the three lower tiers respectively, with some (e.g. INT, STDEV.S) sitting a tier too high. OFFSET and VERSION remain deliberately excluded from every list: both are named by the doc but are protected built-ins outside the token system today (see hf-306-token-vocabulary-final memory for the two different root causes and what closing each would take - out of scope here). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
A scoped spec-to-ship review of PR 4 (19.08) confirmed 5 findings of 11 raised. Two
are documentation defects in this PR's own new text, and both were measured rather
than read:
- The guide said "Custom functions you register yourself are always available,
whatever your key grants", and the JSDoc this PR adds to getAvailableFunctions
repeated it. The actual rule in licenseListsFunction is "not covered by the
capability table", NOT "not user-registered". Verified on a crm-tier key: after
registering an own plugin implementing BITAND, getFunctionDetails('BITAND')
returned undefined and =BITAND() returned #LIC!. An integrator following the
guide would ship a picker that hides their own function and a sheet that errors.
Note getFunctionDetails' own JSDoc already called this "the exception", so the
guide contradicted the API reference it links to - both are corrected here, in
one pass, because fixing only the guide leaves the JSDoc wrong.
- The guide said a missing/invalid/expired key means "every function evaluates to
a #LIC! error" and "stops formulas from calculating until you fix the key". Both
overshoot. Measured with licenseKey: '': =VERSION() returned a version string and
=OFFSET(A1,0,1) returned 2 - the two protected built-ins are exempt at the
interpreter's gate - while =SUM(A1:B1) returned #LIC! and =A1+B1 kept calculating.
Now says "every function call", with a paragraph naming what keeps working, so a
user whose key lapsed is not told to expect a blank sheet.
No behaviour change in this commit. The paired tests PR carries the two test-level
fixes from the same review (capability-table membership pinned by name rather than
by count only, and the protected built-ins asserted on the LISTING path, not just
on evaluation).
Full private suite: 512 suites, 6383 passed, 3 pre-existing skips, 0 failures.
tsc --noEmit and tsc -p tsconfig.test.json clean; eslint 0 errors.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
Three corrections to the same claim, all of them user-visible: - "every function call evaluates to `#LIC!`" is false. `VERSION()` and `OFFSET()` are function calls and they bypass both gates, because the interpreter exempts protected built-ins before it consults the licence. Under a bad key `SUM()` returns `#LIC!` while those two keep evaluating. - The guide's next paragraph attached `VERSION()`/`OFFSET()` grammatically to "are not function calls", which states the opposite of what they are. Operators are not function calls; those two are, and are exempt for a different reason. Split accordingly. - "Methods that only read data never throw" is misleading: `copy()` and `cut()` mutate nothing and return values, yet both are gated by the clipboard feature and throw without it. Reported by Tobiadefami on this PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The licence comments carried two things this repository has no precedent for: the first name of a colleague attached to business decisions, and the ids of internal planning documents. Neither is resolvable by anyone reading the published package, and both were about to enter the public history permanently - a squash merge puts each branch's final content on develop, so removing them further up the stack would not have helped. The substance is untouched: every comment still says what was decided and why, and the HF-nnn task references stay, since those already appear in this repository. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e9863f2 to
cd79b06
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## hf-307-entitlement-gating-pr3 #1731 +/- ##
==============================================================
Coverage 97.38% 97.38%
==============================================================
Files 204 204
Lines 16200 16214 +14
Branches 3483 3487 +4
==============================================================
+ Hits 15776 15790 +14
Misses 424 424
🚀 New features to boost your workflow:
|
8dcca66 to
e88b96b
Compare
|
Folded into #1730 so that no PR in the stack shows code a later PR rewrites; the content is unchanged, just consolidated into one PR. |
Last of four. Stacks on
hf-307-entitlement-gating-pr3(#1730) — merge that first. Tests: handsontable/hyperformula-tests#33.Context
HF-307, decision D2.
getAvailableFunctions()andgetFunctionDetails()read straight from the function registry with no license filter, while the interpreter gates the same functions per call. A restricted key therefore advertised functions that return#LIC!when called — precisely the failure #1724 (HF-349) removed the static variants to prevent:That PR removed the static methods on the rationale that "an instance knows its license key, so it can answer for the engine the caller actually holds". The instance methods never learned to read it. This finishes the job.
The change
Both methods now filter through
licenseListsFunction, which shares onelicenseAllowsFunctionrule with the interpreter rather than spelling the same condition out twice, and canonicalises aliases the same way. Extracting that rule is the point of the PR as much as the filter is: two copies would drift, and the drift is invisible until a customer's function picker offers something that fails.The one decision worth reviewing
The filter reads gate B only — never the key's validity state.
A missing, invalid or expired key resolves to an unrestricted entitlement (the invariant PR 3 documents), so it reaches the filter with
unrestrictedset and keeps the whole catalogue. That falls out of the invariant rather than being a second decision — but it is a deliberate one, and the alternative is defensible, so it is worth an explicit look.I chose it because narrowing on gate A would return two functions (the protected built-ins) to anyone who calls the API before configuring a key — an empty function picker with no clue why, for the exact integrator still wiring things up. A key problem is already reported on the console and by
#LIC!in cells. The list narrows only for a valid key that genuinely excludes a function, which is when the answer is useful.Pinned by tests in both directions; folding gate A into the filter fails 3 of them.
Also
types-of-errors.mddescribed#LIC!as only ever meaning a key problem. It now also means "not in your package".How did you test your changes?
npx tsc --noEmit: clean.npm run lint: 0 errorsnpm run docs:generate-function-docs: succeeds, and the generated reference still documentsBITAND/VLOOKUP/XIRR— the generator builds withgpl-v3, so the published docs do not narrow to a tierNot run here:
npm run test:browser(Karma needs Firefox, unavailable in this environment). No Jest-only matcher forms were used.Types of changes
Not marked breaking: the narrowing only happens for a valid restricted key, and no such key can exist for a released version yet.
Related issues
Checklist
The three compatibility boxes are left unticked as not applicable: this change touches no formula semantics.
Found while working on this, NOT fixed here
=OFFSET()with no arguments throws aTypeErrorout of the parser instead of returning an error value —handleOffsetHeuristic(src/parser/FormulaParser.ts:764) readsargs[0].typewith no arity check. Pre-existing, unrelated to licensing, and out of scope; flagging it for a separate issue.getRegisteredFunctionNames()still returns the whole catalogue under a restricted key — measured: 422 names on acrm-tier key, including functions that evaluate to#LIC!. Two independent reviewers in the 19.08 pass raised it and both times it was refuted on scope, not on accuracy: it is a registry/i18n surface ("what is registered, translated"), not the "what can this engine compute" surface this PR narrows, it sits outside this PR's diff, and it has a static counterpart that would have to move with it. Recording it because a function picker built on that method has exactly the problem #1724 and this PR exist to prevent, so somebody should decide deliberately rather than by omission. Not changed here.Update 19.08 — spec-to-ship review: 5 confirmed of 11
Five dimensions, every finding adversarially verified by an agent tasked with refuting it. Two documentation defects fixed in
e9863f27here; two test-level gaps fixed in the paired PR; one stale-numbers fix above. Six findings were refuted, including two independent reports thatgetFunctionPlugin()/getAllFunctionPlugins()leak the catalogue — real behaviour, but no promise in this PR is broken by it.Both docs defects were measured, not read. The guide promised custom functions are "always available, whatever your key grants" and the JSDoc added here repeated it; the real rule is "not covered by the capability table", so a plugin registered under a built-in id the key excludes is hidden and returns
#LIC!(verified with an own plugin implementingBITANDon acrmkey). Separately the guide said a bad key means "every function evaluates to#LIC!" and "stops formulas from calculating" — butVERSION(),OFFSET()and all arithmetic keep working, so a user whose key lapsed was told to expect a blank sheet.The capability table was pinned by cardinality only — the size, nesting and registry-completeness checks cannot see a count-preserving move, and only 6 of ~340 entries were named anywhere in the suite. Swapping
INTwithSUMIFacross the spreadsheet/calculated-fields boundary passed all 193 license tests and all 128 metadata-API tests; a second swap at a different boundary survived too. Each tier's sorted membership is now checked in and compared by name, so any re-derivation is a reviewable diff. This matters most for a table transcribed by hand from a doc.The one test named for the protected built-ins asserted evaluation, not listing, justified by a comment claiming
OFFSET/VERSIONare "excluded from the listable ids entirely" — false,getListableFunctionIds()returns them, andgetAvailableFunctions()does contain both. Dropping thefunctionIsProtectedshort-circuit and folding the two ids into the table removed them from every restricted key's picker while=OFFSET(...)still evaluated, with the suite green. Now asserted on the listing path, with a floor case for a Handsontable-only key.Checked and clean:
baseRefNamecorrect for last-of-four, head SHAs match, all checks completed and passed on both PRs, no unresolved review threads, and no key material or internal URLs in the public diff. The one thread on the tests PR was marked resolved while the text it flagged was still wrong — that text is what finding 2 above corrects.Note
Medium Risk
Changes public metadata API behavior and the authoritative function-to-package mapping for restricted keys; wrong drift between interpreter and listing would confuse integrators, though invalid keys intentionally keep the full catalogue.
Overview
getAvailableFunctions()andgetFunctionDetails()now list only functions the instance’s valid license actually allows, using the same gate-B rule as the interpreter via sharedlicenseAllowsFunction(andlicenseListsFunctionfor listing). That closes the gap where a restricted key could advertise functions that evaluate to#LIC!.A missing, invalid, or expired key does not shrink the catalogue—gate A is handled separately in evaluation (
#LIC!in cells), so integrators can still build a picker before a key is wired.VERSION()/OFFSET()and plain arithmetic stay outside this filter.Docs and changelog document feature packages,
#LIC!for “not in your package,” and the custom-function rule: own ids stay available; registering under a built-in id your key excludes is treated like that built-in.The capability membership tables in
capabilities.tsare re-derived from the internal packaging doc (tier counts and per-package function lists change accordingly).Reviewed by Cursor Bugbot for commit cd79b06. Bugbot is set up for automated code reviews on this repo. Configure here.