Repository navigation
Take the license-key reader from tag 5.0.0 - #1794
Open
marcin-kordas-hoc wants to merge 34 commits into
Open
marcin-kordas-hoc wants to merge 34 commits into
marcin-kordas-hoc wants to merge 34 commits into
Conversation
Implements tasks 1.1-1.5 of the HF-307 spec (rev 2.4): the license entitlement model, the capability registry, and gate B in the interpreter's FUNCTION_CALL evaluation. Gate A (existing key-validity check) is untouched. New: - src/license/LicenseEntitlement.ts: FeatureId, LicenseExpiry, LicenseEntitlement, unrestrictedEntitlement() - src/license/capabilities.ts: CapabilityGrant, CAPABILITY_TABLE (placeholder: every built-in under one 'core' token, refreshed from the static function registry on every CapabilityRegistry construction rather than at module load, since src/index.ts registers built-in plugins only after Config/Interpreter have already been evaluated) - src/license/CapabilityRegistry.ts: resolve() (transitive, cycle-safe `implies` expansion), capabilityOf(), allowsFunction(), allowsFeature() Modified: - src/Config.ts: licenseCapabilities/isLicenseGateActive/ capabilityRegistry added to the existing privatePool WeakMap, never exposed through getConfig() - src/interpreter/Interpreter.ts: gate B added after gate A in the FUNCTION_CALL case, with a custom-function exemption predicate (capabilityOf() === undefined) and alias canonicalization so an alias gates identically to its canonical name - src/error-message.ts: ErrorMessage.LicenseCapability Decision deltas applied (Kuba, 2026-08-10): D1 (custom_functions token dropped this release, FeatureId.CustomFunctions kept as reserved vocabulary) and D3 (fail-closed + silent: an entitlement with no recognized token grants only core, without a message, warning, or diagnostics getter - unrestrictedEntitlement() no longer covers that case). D2 and D4 are out of scope for this PR. PR 1 ships without a real license-key payload adapter (PR 3), so Config always resolves an unrestricted entitlement for now - gate B is a correct, independently-testable no-op in production until PR 3 lands. Full PR description, verification notes, and drafted private-repo tests (no credentials available this session for hyperformula-tests): marcin-kb/handoffs/hf-307-pr1-tests/ Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GuUdq242TtFaepKRNdEkj9
…ants No grant in the table ever named another token, and the table is documented as holding its entries fully expanded, so the recursive walk resolved nothing that a flat pass over the entitlement's own tokens does not. Removing the field takes the queue, the visited set and the cycle guard with it. Repeating a token still grants what it grants once, which the paired suite now pins directly rather than through implication. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Kuba Sękowski's review of #1728 (src/license/CapabilityRegistry.ts:16): a single `unrestricted: boolean` cannot express a key that grants every function but not every feature, or the reverse - both axes could only be granted together or neither. ResolvedCapabilities.functions and .features are now each `ReadonlySet<T> | 'all'`, and allowsFunction/allowsFeature check their own axis independently. `unrestricted` carries a fail-open invariant (resolveLicense: a missing, invalid or expired key resolves to an unrestricted entitlement, by design - gate A already stops evaluation on a bad key, so gate B must not turn a lapsed legacy key into a throwing API). Splitting it opens a matching fail-closed trap: an edit that sets 'all' on one axis and forgets the other silently narrows a working install instead of leaking anything. Both axes are set in the same object literal in CapabilityRegistry.resolve() for exactly that reason, and the paired test commit pins each axis separately so a one-axis regression fails only one test. Does not touch LicenseEntitlement.unrestricted, a distinct field on the pre-resolution entitlement that this change leaves alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
> **Update 2026-09-22 — review round with @sequba.** The sections below are the PR as written > before that round, kept for the reasoning they record. Where a sentence no longer matches the > code it is corrected inline and marked, rather than rewritten away. ### Context HF-307 (feature packages / entitlement gating). This is PR 2 of 4: guards on the public API. Stacked on hf-307-entitlement-gating-pr1 (#1728) - depends on `CapabilityRegistry`/`allowsFeature`/`FeatureId`/`Config.licenseCapabilities` from that PR. Mechanical, public-repo-only, no key-format knowledge needed. New: - `src/errors.ts` - `LicenseCapabilityMissingError`, mirroring the existing ~30 error classes. Now also exported from the public entrypoint (`src/index.ts`) - see finding 6. - `src/HyperFormula.ts` - private `ensureCapability(feature: FeatureId)`, mirroring `ensureEvaluationIsNotSuspended`: a single `isLicenseGateActive` boolean read on the fast path, then `allowsFeature` against the resolved entitlement. - `src/BuildEngineFactory.ts` - private static `ensureNamedExpressionsCapability(config, namedExpressions)`, the build-time counterpart (task 2.3). - ~~`src/CrudOperations.ts` - public `isCutClipboard()`~~ **removed 22.09**: the reviewer accepted the cut-paste bypass, so the second capability check it fed is gone and the wrapper with it. See the correction under finding 6. Modified: `ensureCapability` wired as the **first statement** (before argument validation - a license error should beat a type error) in the ~20 methods from spec §05 task 2.2: - NamedExpressions: `addNamedExpression`, `changeNamedExpression`, `removeNamedExpression` - Clipboard: `copy`, `cut`, `paste` — Clipboard only. (The conditional **Crud** check described under finding 6 was removed 22.09.) - Crud: `addRows`, `removeRows`, `addColumns`, `removeColumns`, `moveCells`, `moveRows`, `moveColumns`, `addSheet`, `removeSheet`, `clearSheet`, `setSheetContent`, `renameSheet`, `setCellContents`, `swapRowIndexes`, `setRowOrder`, `swapColumnIndexes`, `setColumnOrder` - UndoRedo: `undo`, `redo` - Batching: `batch`, `suspendEvaluation` — but **not** `resumeEvaluation`, see finding 4 below `CustomFunctions` is not gated anywhere, per HF-307 decision D1. `ImportExport` has no methods to gate yet (HF-107). **Where the line is drawn** (written into the `ensureCapability` JSDoc so a later change has to move it on purpose): - **Gated** — mutations that create value: the sheet, the clipboard, the undo history, the named-expression set. - **Not gated** — reads (`getCellValue`, `listNamedExpressions`, `getAllNamedExpressionsSerialized`, the `isItPossibleTo*` predicates) and teardown that only ever *removes* state (`clearClipboard`, `clearUndoStack`, `clearRedoStack`, `destroy`). Gating cleanup would let a restricted entitlement strand an integration mid-teardown while giving a licensee nothing. This mirrors gate B, which blocks *calling* a function rather than *reading* an already-computed value. There is a test pinning this, because "we chose not to gate cleanup" is otherwise indistinguishable from "we forgot these" — which is exactly how the reordering gap below survived the first pass, and how the cut/paste gap in finding 6 survived this one. **Open question resolved:** the handoff left "is `getAllNamedExpressions` gated or read-only-exempt?" open. There's no such method today (it's `listNamedExpressions`, `getNamedExpression`, `getAllNamedExpressionsSerialized`) - I left all three ungated. This follows gate B's own precedent: it blocks *calling* a function, not *reading* a cell's already-computed value, so a restricted entitlement can still see named expressions that already exist. Flagging for reviewers in case the intended scope was broader. Task 2.3: `BuildEngineFactory.ensureNamedExpressionsCapability` runs the same `allowsFeature(FeatureId.NamedExpressions)` check when the `namedExpressions` argument to `buildFromSheets`/`buildFromSheet`/`buildEmpty` (what `buildFromArray`/`buildFromSheets`/`buildEmpty` resolve to) is non-empty; an empty list is never checked. Deliberately **not** applied to `rebuildWithConfig` - that path re-serializes named expressions an already-built instance was already allowed to create (e.g. on `updateConfig()`), rather than accepting them fresh from a caller, so a later entitlement change must not retroactively break an existing instance's own state. This ships without a real license-key payload adapter (PR 3), so every entitlement `Config` can produce today is unrestricted - `ensureCapability` and the build-time check are correct, independently-testable no-ops in production until that adapter lands, exactly like gate B in PR 1. ### Self-review findings (after the PR was first opened) Recording these in the open rather than quietly amending, since several are things a reviewer should get to disagree with. Findings 4 and 5 came from Cursor Bugbot's inline comments and were confirmed here before fixing. Finding 6 came from an independent spec-to-ship review run against this PR specifically after it had already been open for review (a 5-dimension multi-agent pass: guard completeness, test-quality-by-mutation, the shipped build artifact, docs/CHANGELOG accuracy, and merge mechanics - each finding adversarially re-verified before being reported). **1. The Crud gate was bypassable — fixed in `8a26e2be7`.** The first pass gated `moveRows`/`moveColumns` but missed four structurally identical methods: `swapRowIndexes`, `setRowOrder`, `swapColumnIndexes`, `setColumnOrder`. A restricted entitlement with no Crud grant could still permute every row and column in a sheet, which defeats the gate for a whole class of structural mutation. All four are now gated *in their own right* rather than relying on the `swap*` method that the `set*Order` pair delegates to — otherwise `set*Order`'s own `mappingFromOrder` validation would run first and a type error would beat the license error, violating task 2.2's first-statement rule. **2. `ensureCapability` checks gate B only, never gate A — deliberate, and now an explicit invariant.** Today a missing or invalid key yields `#LIC!` in cells while the CRUD API keeps working; this PR preserves that exactly. The risk is forward-looking: if PR 3's key adapter resolves an invalid key to a *restricted* entitlement rather than an unrestricted one, every gated method here starts throwing and that becomes a silent breaking API change. This PR is what makes that reachable, so the invariant is documented at the guard itself. **Reviewers: worth confirming this is the intended split before PR 3 lands.** **3. Known evidence gap in the task 2.3 tests.** The `BuildEngineFactory` gate is exercised by calling the private static directly, because the public path (`buildFromArray(sheet, config, namedExpressions)` → throws) *cannot* be reached today — the factories construct their own `Config`, and every `Config` currently resolves unrestricted. So Codecov's 100% patch coverage overstates the evidence for that one check: the integration path is unproven until PR 3 makes a restricted `Config` constructible. Flagging rather than papering over it. **4. Gating `resumeEvaluation` could brick an engine — fixed in `c29a9ba40`.** Raised by Bugbot, confirmed. `resumeEvaluation` is the *only* exit from a suspended engine, and `_evaluationSuspended` survives `rebuildWithConfig`. So: suspend while Batching is granted → `updateConfig` produces an entitlement without Batching → the instance is permanently unusable, because every read throws `EvaluationSuspendedError` and the sole recovery path threw `LicenseCapabilityMissingError`. It is now ungated. `suspendEvaluation` and `batch` stay gated — those are the entry points that make the feature worth licensing, and if you cannot *enter* batching you can never extract value from it. Generalised into a rule on `ensureCapability` so it does not get re-added: **a capability check must never be reachable only on the way *out* of a state it let the caller into** — the same reasoning that leaves teardown ungated. **5. One of PR 1's tests was vacuous — fixed in the paired tests PR.** Also raised by Bugbot. PR 1's custom-function-exemption test built the sheet with `'=CUSTOMFUNC()'` already in it, so the formula was evaluated during `buildFromArray` while the entitlement was still unrestricted; `restrictEngine` then changed nothing that `getCellValue` would re-read, and the assertion passed against a cached value. I verified this rather than assuming it — deleting the exemption from `Interpreter.ts` outright left the test **still passing**. It now writes the formula via `setCellContents` *after* restricting, matching the shape its sibling tests already had, and the same mutation now correctly fails it. The two new `resumeEvaluation` regression tests were verified the same way (re-adding the gate fails them). **6. `paste` was a hard-gating bypass, plus a packaging gap and a documentation gap — found by an independent spec-to-ship review, all fixed.** - **Bypass (HIGH).** `paste()` checked only `FeatureId.Clipboard`, but `CrudOperations.paste()` dispatches to `moveCells()` internally when the clipboard holds a cut - the exact cell-relocating mutation the public `moveCells()` requires **Crud** for. A `Clipboard`-only entitlement could reach it via `cut()`+`paste()`, with no Crud grant ever checked. Reproduced live (the move fully executed, no exception, under a Clipboard-only entitlement) before fixing. Fixed by adding `CrudOperations.isCutClipboard()` and additionally gating `paste()` on `Crud` when the clipboard holds a cut - still checked before argument validation. **Reversed 22.09 at the reviewer's request** ("Drop this check. We accept this bypass"): granting the clipboard is taken to grant what the clipboard does, so this route is deliberately not gated on Crud as well. The check, the wrapper and the stale `@throws` tag are gone; the paired spec now pins the acceptance - a clipboard-only entitlement relocates the cell - so the hole cannot be closed again by accident. - **Packaging gap (HIGH).** `LicenseCapabilityMissingError` was never imported/exported from `src/index.ts`, so it was unreachable from the package's public entrypoint: `import {LicenseCapabilityMissingError} from 'hyperformula'` failed to compile (`TS2614`), the default-export static form was `undefined`, and no deep import worked either (the `exports` map in `package.json` only defines `.` and the i18n subpaths). A consumer had no supported way to catch this error by type - the existing test suite never caught it because it imports the class directly from `src/errors`, bypassing the public entrypoint entirely. Fixed by exporting it alongside the other ~30 error classes already there. - **Documentation gap (MEDIUM).** None of the 27 gated methods carried a `@throws [[LicenseCapabilityMissingError]]` tag, breaking this file's own established per-method `@throws` convention (every *other* exception type already gets one). The class-level `@see` list on `LicenseCapabilityMissingError` itself also wrongly named `resumeEvaluation` (which finding 4 deliberately excludes) and omitted all 17 Crud methods. Fixed: all 27 gated instance methods plus the 3 static build factories now carry the tag, and the `@see` list is corrected and complete. The same review also generalised finding coverage on the test side: mutation-testing every one of the 27 `ensureCapability` call sites found 17 with zero regression protection of their own (the "one test per feature group" strategy only pinned the group representative) - see the paired tests PR for the added coverage. *Process note, since it generalises:* Bugbot's findings 4 and 5 were missed by the automated review tier that scrapes them (a regex looking for `Severity: Medium` against Bugbot's actual `**Medium Severity**`), which reported a clean PASS. Finding 6 was missed by both Bugbot and a prior manual review pass (which had only caught the narrower, single-method version of the test-coverage gap). Worth reading Bugbot's inline comments directly rather than trusting an aggregator, and worth an independent adversarial pass even after a PR looks clean. ### How did you test your changes? - `tsc --noEmit` and `tsc -p tsconfig.test.json`: clean. - `eslint` on all changed files: 0 errors (pre-existing warning categories only, none introduced by this change). - Manual verification against the built engine (ts-node scratch script, not committed): every gated method throws `LicenseCapabilityMissingError` under a simulated restricted entitlement, an unrestricted entitlement (today's default) is unaffected, and `buildFromArray` with named expressions under the default unrestricted entitlement does not throw. Re-verified against a freshly rebuilt `commonjs` package for finding 6 specifically (the cut+paste fix and the `index.ts` export). - Private test suite pushed: [hyperformula-tests#31](handsontable/hyperformula-tests#31) - `unit/license/public-api-guards.spec.ts` (42 tests: one positive + one negative per feature group, a validation-order test, the build-time bypass pair, five covering the reordering gap, one pinning the ungated-cleanup rule, two for the `resumeEvaluation` deadlock, one dedicated throw test per previously-group-only-covered method (15 of them), the cut/paste bypass control+regression pairs, and one proving `LicenseCapabilityMissingError` is reachable from the public entrypoint) plus fixes to PR 1's `unit/licence.spec.ts` (the test-helper interaction described in that PR, and the vacuous exemption test in finding 5). - **Mutation-checked, not just green:** the two `resumeEvaluation` tests, the repaired custom-function test, the cut+paste bypass fix itself, and a sample of the newly-added per-method tests (`removeRows`, `changeNamedExpression`) were each verified by reverting the corresponding source line and confirming the new test fails, then restoring. - Full suite against the paired branch: **511 suites / 6373 tests, 6370 passed, 3 pre-existing skips, 0 failures.** ### Types of changes - [ ] Breaking change - [x] New feature or improvement - [x] Bug fix - [ ] Additional language file, or a change to an existing language file (translations) - [ ] Change to the documentation ### Related issues: 1. HF-307 (internal tracker; no public GitHub issue for this one) ### Checklist: - [x] I have reviewed the guidelines about Contributing to HyperFormula and I confirm that my code follows the code style of this project. - [ ] I have signed the Contributor License Agreement. *(please confirm/attach on your end - I can't verify this from here)* - [ ] My change is compliant with the OpenDocument standard. *(N/A - no worksheet function behaviour changes in this PR)* - [ ] My change is compatible with Microsoft Excel. *(N/A - same reason)* - [ ] My change is compatible with Google Sheets. *(N/A - same reason)* - [ ] I described my changes in the CHANGELOG.md file. *(intentionally not done - internal-only change, no user-visible behaviour yet; a CHANGELOG entry lands with the PR that actually activates the gates for customers)* - [x] My changes require a documentation update. *(done - @throws tags and the @see list on LicenseCapabilityMissingError, see finding 6)* - [ ] My changes require a migration guide. *(no)* <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Wide API surface change: many mutation entry points can throw when restricted entitlements land in PR 3; deliberate exclusions (ungated `resumeEvaluation`, Clipboard-only paste) affect runtime behavior under partial licenses. > > **Overview** > Adds **license entitlement guards (HF-307 PR 2)** on the public HyperFormula API so restricted entitlements throw `LicenseCapabilityMissingError` instead of mutating the engine. > > A private `ensureCapability` runs as the **first statement** on gated instance methods when `isLicenseGateActive` is true: **Crud** (sheet/cell/row/column moves and structural edits), **UndoRedo**, **Clipboard** (`copy`/`cut`/`paste`—paste is Clipboard-only even when a cut relocates cells), **Batching** (`batch`, `suspendEvaluation` only; **`resumeEvaluation` stays ungated** so entitlement changes cannot strand a suspended instance), and **NamedExpressions** (add/change/remove). Reads, `isItPossibleTo*` helpers, and teardown (`clearClipboard`, undo/redo stack clears, `destroy`) stay ungated. > > **Build-time:** `BuildEngineFactory.ensureNamedExpressionsCapability` rejects non-empty initial `namedExpressions` without the feature on `buildFromSheets`/`buildFromSheet`/`buildEmpty`, but not on `rebuildWithConfig`. The new error is exported from `src/index.ts`, with `@throws` docs on affected methods. Until PR 3 supplies restricted entitlements, these checks are no-ops for today’s default configs. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 690ef69. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…n, and capability table (#1730) > **Update 2026-09-22 — review round with @sequba.** The sections below are the PR as written > before that round, kept for the reasoning they record. Where a sentence no longer matches the > code it is corrected inline and marked, rather than rewritten away. ## Update 2026-09-18 — folded #1731, #1736, #1737, #1740, #1741 into this PR So that no PR in this stack shows code a later PR rewrites, this PR now contains, squashed with no loss of content: the entitlement reader (rev 6, was #1740), the notice-window warning (was #1736), the two add-on tokens (was #1737), the function-metadata filtering (was #1731), and both capability-token dialects (was #1741). Those five PRs are closed, folded here. The stack is now `#1728 → #1729 → this PR → #1743`. Two things below are stale as a result and are corrected here rather than edited in place, so the history stays legible: the title above (fixed), and every PR-number reference in the body below still says `#1740`/`#1741`/etc. — read those as "now #1730". The CHANGELOG entries in the diff are already repointed to #1730. Everything below this line is the PR as it was written across 2026-08-11 through 2026-08-26, before the fold — the decisions and verification it documents did not change. --- Third of four. Stacked on PR 2 (#1729), so review the commits on top of it. **Paired tests: handsontable/hyperformula-tests#32 — merge that first.** ## Why this PR exists at all **A genuine typed license key did not work before this.** `checkLicenseKeyValidity` recognizes three fixed strings and the older 25-character format; a typed key matches none of them and falls through to `INVALID`. I verified this by building an engine with a real, unexpired `[SUB]` key before touching anything: ``` validityState = "invalid" C1 = #LIC! "License key is invalid." ``` So a paying customer pasting a valid key got `#LIC!` in every cell plus a console warning. An entitlement adapter alone would not have helped — gate A kills the formulas first. ## What lands here **1. Vendor the typed-key reader** into `src/license/handsontable-license-key-parser/` (~~`src/license/vendor/`~~, **renamed 22.09** at the reviewer's request so the directory names the repository it mirrors) as TypeScript (`allowJs` is off, `strict` is on, so this is a port, not a copy). This follows the key spec's own recommendation of a vendored copy plus 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, so drift is detectable by re-cloning and re-hashing, and lists the deliberate divergences. **2. Resolve typed keys into both gates.** `resolveLicense` reads the key once and answers both gates from that single reading, so they cannot disagree about what the string says. Anything that is not a typed key — `gpl-v3`, an older-format key, an empty string, a typed key with a broken checksum — falls through to `checkLicenseKeyValidity` with its **verdict unchanged**. That function is extracted from rather than rewritten, so both paths share one warning-per-page flag instead of each getting their own. Two additive edits to the file it lives in, both affecting the legacy path too and both deliberate: `formatDate` now reads its date in UTC (it printed the expiry a day early west of UTC — a latent bug on the legacy path as well), and an `@internal` reset for the warn-once flag was added because it made the console path untestable. **3. The real capability table**, plus reading both payload shapes. ## Please confirm two things **The gate-A/gate-B invariant.** Only a VALID typed key resolves to a restricted entitlement; missing, invalid and expired all resolve to `unrestrictedEntitlement()`. 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. Mutation-tested. **The capability table is a DRAFT.** Membership is transcribed from the packaging design's own per-function evidence file rather than invented, and the transcript is checked two ways: it reproduces that file's five published counts exactly (370 rows; 17 / 51 / 127 / 355 cumulative plus 15 operators), and all 423 registered function ids resolve into the 370 canonical entries once the 53 declared aliases are canonicalised, with zero uncovered. But the design is still under review — the free tier's exact contents and the placement of several function families are not settled. Landing it now was a deliberate call, not a claim that it is final. ## Judgement calls worth a reviewer's eye - **Both payload shapes are read**, detected per product entry by the presence of `capabilities`: the shipped shape (`tier`/`addons`/`exp`) and the newer specified shape (`capabilities`/`usage_until`/`release_until`/`notice`/`flags`). They disagree about nearly every field, the newer one is still under review, and only the first can be minted today. The newer spec also contradicts itself on whether its dates are `YYYY-MM-DD` strings or numeric timestamps, so both are accepted. - **Feature gating is REAL** (updated 12.08, per Kuba's answers on the task). An earlier revision of this PR granted all five feature areas from the `core` token, which made feature gating inert by construction. The five features now live on their own `feat:*` tokens: a rev-5 key grants exactly the `feat:*` tokens it carries; the shipped shape — whose vocabulary predates feature tokens — is granted all five by the adapter, so a shipped-shape key's API behaviour is unchanged; legacy keys resolve unrestricted (Kuba's carve-out). The exits stay ungated (`resumeEvaluation`, teardown), per the never-gate-the-exit rule. - **An unrecognized token no longer silences the key** (updated 12.08). Silence comes solely from the key's `flags`; the earlier coupling (`silent || unrecognized > 0`) suppressed strictly more than D3 asks for and was confirmed an implementation error by Kuba. - **The two add-on tokens are recognized but empty.** `spreadsheet` has no agreed content — the packaging design names a *package* "Spreadsheet" and the pricing work names a "Spreadsheet Bundle" *add-on*, and whether those are the same set is unsettled; guessing either way silently sells an empty add-on or duplicates a whole tier. Pinned by a test so filling it has to be deliberate. - **`noticeDays` is 0 for the shipped shape**, which has no notice field. Inventing a default in the parser would put a product decision in the wrong place. The newer shape carries `notice` and it is used. - **`PROVENANCE.md` names the upstream private repo and commit.** That is what makes the documented drift check runnable. Ratified by Kuba on the task (D7-A, 12.08): kept as-is. ## Update 13.08 — key spec rev 5 read against the code Reading rev 5 ([doc](https://app.clickup.com/9015210959/docs/8cnjcyf-31675/8cnjcyf-48155), updated 12.08) and running its own example payload through this branch turned up three things, all fixed in the last commit: - **Feature tokens are now OPT-IN.** §2.2 lists HyperFormula's entire token vocabulary as `functions_1..4`, `spreadsheet`, `import_export` — there is no `feat:*` entry at all. So a key that names no feature token cannot be saying "no features"; nothing in circulation can express one. Minting the spec's own §2 payload and running it: `setCellContents`, `addRows`, `copy`, `undo`, `addNamedExpression` and `batch` **all threw**. The same hole hit Handsontable-only keys and keys with `hyperformula: null`, which lost the API that `core` used to give them, silently (gate-A VALID = no console warning). A key that *does* name a `feat:*` token still gets exactly what it names, which is the gating Kuba asked for. - **`no-console-warns` is honoured.** rev 5 spells the flag three ways — §2.3 and the §2 example say `no-console-warns`, §4.3/§5.2 say `silent-console`, earlier revisions said `silent`. Only the last two were recognised, so a doc-conformant SaaS key printed console warnings it had explicitly asked to suppress. - **An unreadable `capabilities` rejects the key.** Present-but-not-an-array fell through to the shipped-shape branch, which was a free pass twice: every feature granted, and the rev-5 dates never read — a subscription expired in 2020 resolved as perpetual. **Still divergent from rev 5, deliberately:** §4.1 says a non-trial key never hard-blocks, even past grace ("Trial: block. Non-trial: error only (18.1)"). This branch keeps EXPIRED → `#LIC!`, per Kuba's D5=A (hard blocking stays this release; the notice/soft-stop/hard-stop model is a follow-up). ## Verification Full unit suite green — **512 suites, 6344 tests** (after the 13.08 update). Each of the three fixes above is mutation-verified: removing the opt-in rule fails 9 tests, removing `no-console-warns` fails 1, removing the `capabilities` guard fails 2, and cutting the console notification fails 1 (it used to fail none). Beyond that, the parts that matter were mutation-tested rather than trusted: a wrong SHA-512 round constant, a one-bit UTF-8 error, a broken invariant and a moved package boundary each fail the tests that are supposed to catch them. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Changes commercial licensing, formula evaluation, and API gating paths; incorrect gate-A/gate-B or capability resolution could block paying customers or expose paid features. > > **Overview** > Adds **proprietary license entitlements**: vendored **entitlement-key** parsing (rev 6) and **`resolveLicense`** wire `Config` to gate A (key validity → `#LIC!` on function calls) and gate B (capability tokens → restricted functions/API). Legacy/`gpl-v3` paths stay on **`checkLicenseKeyValidity`**; only **valid** entitlement keys get a restricted entitlement—missing/invalid/expired keys still resolve **unrestricted** so the CRUD API does not start throwing while formulas fail. > > Replaces the placeholder **`core`** grant with a static **`fun:*` / `feat:*`** capability table (group tokens, per-function tokens, **`spreadsheet`** and **`import_export`** add-ons), case-insensitive token normalization, and shared **`licenseAllowsFunction`** for the interpreter and **`getAvailableFunctions()` / `getFunctionDetails()`** (lists shrink only for valid partial keys, not for bad keys). > > **Console licensing UX**: split expired messaging for usage vs release axes, UTC date formatting, one-time approaching-expiry notice per key, optional silence via config rebuild and key flags; docs/changelog describe feature packages and `#LIC!` semantics. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 15d7231. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> ## ⛔ Update 18.08 — upstream retired this key format the same day, and one claim above is wrong A scoped spec-to-ship review of this PR (5 dimensions, every finding adversarially verified) confirmed 5 findings. Four are test-hardening and are fixed in the paired PR — see [hyperformula-tests#32](handsontable/hyperformula-tests#32 "Update 18.08". The fifth needs a decision, not a patch, so nothing here has been changed for it yet. **The vendored reader cannot read the format upstream now ships.** `handsontable/license-key` commit `01eae6530d4f` (2026-08-18 08:44 UTC, DEV-2512, "Replace typed license keys with the entitlement key format (rev 6)"), released as **4.0.0**, **deletes `src/typed-key/` outright** — the exact five files `PROVENANCE.md` pins and hashes. Its own docs are explicit: "The typed key format was **removed**, not deprecated… this package cannot read a typed key." The replacement differs in three ways this reader gates on: | | this branch | entitlement format (rev 6 / 4.0.0) | |---|---|---| | format marker | requires a leading `[SUB]_`/`[FREE]_`/`[TRIAL]_`/`[PERP]_` tag (`extractKeyData.ts:147`) | **no tag** — the key *ends* with a bracketed block, which is the only marker | | payload version | requires `v: 1` (`extractKeyData.ts:199`) | **no `v` field at all** — payload is exactly `{"products":{…}}` | | checksum domain | SHA-512 over the whole key body, prose included | over the **encoded payload only** — an explicit reversal, so the prose becomes editable | | unknown product | invalidates the key (`extractKeyData.ts:105`) | tolerated — "kept, and ignored" | Measured, not inferred: an authentic 4.0.0-format key built per upstream's own `build-payload.js` gives `extractTypedKeyData(key)` → `null`, `resolveLicense(key).validityState` → `invalid`, and `C1` → `#LIC!` "License key is invalid.". Passing only the `[…]` block behaves the same. The retired tagged format still resolves correctly on the same build — so this is a format gap, not a robustness one. Independently, Budzio published the byte-level spec for that format this morning as a new page under key-spec rev 6 ("Technical implementation", sections `T1`–`T8`, created 09:45 UTC). It matches upstream and confirms all four rows above. Three of its rules this branch **already satisfies**: checksum verified before any decode or parse ("order is normative"), uppercase hex rejected rather than normalised, and `__proto__`-safe result construction. **What is genuinely at stake, and what is not.** The invariant holds throughout: a rev-6 key resolves INVALID and therefore to `unrestrictedEntitlement()`, so the CRUD API keeps working and only formulas fail (verified). And myHOT PR #73 (merged to `master` 12:37 UTC today, pinning `license-key#4.0.0`) states **"the typed keys never reached production"**, with the legacy 25-character keys still generated in parallel and validating exactly as before — that path is untouched here. So the correction owed to this PR's own opening claim: no customer ever held a typed key, so "a paying customer pasting a valid key got `#LIC!`" describes a format that was never issued. The live exposure is narrower but real — per that PR's surfacing table, **trial orders and the HubSpot trial webhook now surface the entitlement key**, while commercial orders and transactional emails still show the legacy key only. **Why this is not fixed in this PR.** Re-porting is the obvious move and upstream made it cheaper than the original port (`sha512.js` and `utils.js` are unchanged; the new `extractEntitlementKeyData` is deliberately schema-free and built to be vendored, so only the extractor, schema and constants need re-porting). But it is not a patch to slip in: adopting the new shape rules verbatim also makes `capabilities` mandatory and **invalidates every payload shape this branch currently reads**, rev 6 is still status "for review", and the work is arguably [HF-329](https://app.clickup.com/t/86cb119tz)'s (unassigned, due 21.08, untouched since 04.08 — it now has concrete content). Raised on the task as **D9**; the merge freeze until the spec owner returns means escalating is the action, not a delay. Also worth a reviewer's eye, since it sharpens the still-unanswered **D8**: rev 6's own examples use `capabilities: ["functions_1", "spreadsheet"]` / `["core"]` — i.e. **exactly the token names this branch implements** — while the packaging design published 12.08 states its vocabulary is `fun:<family>.<A|B|C>` and that "nothing else about packaging exists at the technical layer". The two documents disagree, and this branch conforms to the key spec rather than to the packaging doc. A key minted in the packaging vocabulary resolves VALID with the whole gated API and **zero functions**, silently (gate A says VALID, so nothing prints, and `unrecognizedCapabilities` is exposed nowhere). That outcome is now pinned by a test in the paired PR — pinned, not endorsed, so whichever way D8 lands has to change it. --- ### Changed in the 2026-09-22 review round - The guide no longer describes the key formats, and the validation section points at the terms of the contract instead of enumerating the two date axes. The changelog entry for the parser change was reworded the same way, since it carried the same description. - `PROVENANCE.md`: two paragraphs about the superseded `src/typed-key/` port removed; the divergence list corrected. Its item 3 described *upstream's* date check as if it were this port's, which has not been true since a type guard was added, and that guard — the only divergence that changes which keys are accepted — was missing from the list entirely. - The expired-key console message no longer blames the installed version when the key ran out on the usage axis. That wording predates this axis and was correct for every key `develop` can see; it is not for a subscription key. - The `implies` field and its recursive expansion are gone (see #1728), so no capability token refers to another. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Kuba Sekowski <jakub.sekowski@handsontable.com>
…cate its static form (#1743) > **Update 2026-09-22 — review round with @sequba.** The sections below are the PR as written > before that round, kept for the reasoning they record. Where a sentence no longer matches the > code it is corrected inline and marked, rather than rewritten away. ## Update 2026-09-18 — rebased onto the collapsed engine/tests PRs `hyperformula#1730` and `hyperformula-tests#32` were folded from five PRs each into one, so this PR's base moved to `#1730`. Nothing in this PR's own diff changed; the "9/9" numbering and the `#1741` base reference below are stale — the stack is now `#1728 → #1729 → #1730 → this PR`. --- 9/9 of the HF-307 stack, stacked on #1741. Pairs with `hyperformula-tests#43` — **merge the tests PR first**. Finishes decision **D2** on the one API it missed. ## The problem `getRegisteredFunctionNames()` returned the whole catalogue under a restricted key (measured: 422 names on a calculated-fields key, including functions that evaluate to `#LIC!`). A function picker built on it has exactly the problem #1724 (HF-349) and #1731's metadata filter exist to prevent. Flagged in #1731's "found, not fixed here" section; this closes it. ## What changed **Instance method** — now lists exactly what the instance can evaluate, sharing the one `licenseListsFunction` rule with the interpreter and the metadata API so the three surfaces cannot drift: - reads `getListableFunctionIds()` instead of `getRegisteredFunctionIds()` — the protected built-ins are included uniformly (`OFFSET` was missing before; `getAvailableFunctions` already listed it); - reads `config.translationPackage` — the instance's own snapshot — instead of a fresh global `getLanguage` lookup, which can report a localized name the instance refuses to evaluate, and which throws once the host unregisters that language code; - filters by the license, with the invariant intact: a missing, invalid, or expired key does **not** shorten the list. **Static method — deprecated, not removed.** See below; this is a change from the first version of this PR. ## The static: deprecated rather than removed (changed after review) The first version of this PR deleted `HyperFormula.getRegisteredFunctionNames(code)`, citing HF-349 as precedent. That was wrong, and the review caught it: ``` $ git show 3.4.0:src/HyperFormula.ts | grep -c "public static getRegisteredFunctionNames" # 1 ``` The method **is in the released 3.4.0 tag**, and HF-349's own commit message says its removal was free precisely because *"Both methods are unreleased, which is the only free moment to remove them."* So the precedent does not extend here: deleting it would be a breaking change in a minor release, against the Semantic Versioning this project states it follows, and DEV_DOCS's Definition of Done would require a migration-guide section that `docs/guide/` has no 3.x home for. So it is now `@deprecated` with the wording this repo already uses for that situation (`arraySizeMethod` / `arrayFunction` in 3.1.0: "deprecated and will be removed in one of the next major releases"), plus a `Deprecated` changelog entry. **Overturnable in one comment** if you would rather take the break now — the direction is Kuba's D2 either way; only the timing changed. The deprecation notice is explicit that the two are not interchangeable: the static translates into any registered language without an engine, so migrating means building one (`HyperFormula.buildEmpty({ language: 'plPL' }).getRegisteredFunctionNames()`). ## D2's precondition, discharged Kuba's D2 answer made the removal conditional: *"perhaps we can remove the static methods, I'll check which methods does formula-builder use"*. That check is done — searched the org, and **both consumers call the instance form, not the static one**: | Consumer | Call site | Form | |---|---|---| | `formula-builder` | `packages/core/src/engine/functionCatalog.ts:81` — `this.engine?.getRegisteredFunctionNames?.()`, in try/catch, declared optional in `engine/types.ts:66` | instance | | `aurasheet` | `src/core/FormulaEngine.ts:97` — `this.hf.getRegisteredFunctionNames()` | instance | Both use it to build a **function picker** (`functionCatalog.ts`; `FormulaAutocompletePlugin.ts`) — the surface #1724's rationale was about — so the license filter added here improves both rather than disturbing them. Under `gpl-v3` or any unrestricted key their lists are unchanged. ## Spec-to-ship review (2026-08-20): also fixed here - **The translation-snapshot change was unpinned.** Mutation-verified: reverting it to the global lookup left 309 tests green, even though the commit message and the new JSDoc both name it. Now pinned by a test that unregisters the language after the engine is built — the scenario that distinguishes the two implementations, since `getLanguage` throws for an unregistered code while the snapshot keeps working. - **The licence guide listed only two of the three narrowing methods.** It now names this one too, in both places. - **The new JSDoc over-claimed parity** with `getAvailableFunctions`: for a function whose translation is the empty string this method returns `''` while that one falls back to the canonical id. The claim is now scoped to the ids and the licence rule, with the naming difference stated. - Reverting the static removal also removed the docs-build change it required, so `docs/.vuepress/config.js` is untouched by this PR again (it no longer builds one engine per documentation page). ## Testing New suite pins the alignment in both directions (agrees with `getAvailableFunctions` name for name; narrows on a restricted key; never narrows on a bad key; aliases gate with their canonical; answers from the instance's own snapshot). Full private suite: **517 suites / 6462 passing, 3 pre-existing skips**; `tsc --noEmit` and ESLint clean. ## Note for review `functions-metadata.spec.ts` now skips listed ids with no plugin: `OFFSET` is listed (it is callable) but parse-time resolved, so it legitimately has no registry metadata. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019pxNP45obT2LZfjitaCv9o ## The codecov/project dip, traced `codecov/project` was red at −0.02% while `codecov/patch` reported 100% of the diff hit. Rather than write that off as a threshold artifact, I measured coverage on this commit and on its base and diffed the per-file numbers: | file | base (8/9) | this PR, before the fix | |---|---|---| | `src/HyperFormula.ts` | 674 / 675 | 675 / 676 | | `src/interpreter/FunctionRegistry.ts` | 130 / 130 | **129 / 130** | | total | 12 933 / 13 268 | 12 933 / 13 269 | So the covered count did not move and one previously covered line stopped executing — a real consequence of this change, not a rounding artifact. The line was the **instance** `FunctionRegistry.prototype.getRegisteredFunctionIds()`, whose only caller in the whole repository was the method this PR rewrites. Nothing in `src/`, nothing in the private suite, and nothing outside (the class is not exported from `src/index.ts`) calls it any more. Removed, since this change is what orphaned it. The static `FunctionRegistry.getRegisteredFunctionIds()` is untouched and still used — by the deprecated static method above and by three specs. For the record, the single uncovered line left in `HyperFormula.ts` is pre-existing and not mine: `removeNamedExpression`'s unreachable `return []`, which already carries a `codecov note` comment explaining why it cannot be hit. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Documentation and deprecation only; no runtime behavior changes in this diff. > > **Overview** > **Deprecates** the static `HyperFormula.getRegisteredFunctionNames(code)` API (removal planned in a future major) and documents how it differs from the instance method. > > The static form is documented as reflecting the **global** function registry and any registered language without an engine; callers should migrate via `HyperFormula.buildEmpty({ language: '…' }).getRegisteredFunctionNames()`. The **instance** `getRegisteredFunctionNames()` JSDoc now states it lists everything in the instance registry (registration, not license entitlement) and points license-aware UIs to `getAvailableFunctions()`. > > `CHANGELOG.md` adds a **Deprecated** entry for the static method. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit a8ffef3. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --- ### Open in the 2026-09-22 review round - @sequba: "`getRegisteredFunctionNames` should still list ALL functions". Agreed on the principle — this method answers for the REGISTRY, and registration is not entitlement, while `getAvailableFunctions()` and `getFunctionDetails()` answer about availability and stay filtered. That removes most of this PR's substance, leaving the deprecation note and a docs correction. **Awaiting a decision on whether to reduce this PR to that or close it with the deprecation folded into #1730.** - Fixed here meanwhile: `docs/guide/release-notes.md` now carries the same retroactive `### Removed` section for 3.4.0 that `CHANGELOG.md` gained in this PR's history. The two are mirrors and only one had it. --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Kuba Sekowski <jakub.sekowski@handsontable.com>
…flag Four things from the 24.09 review. The engine no longer knows any add-on. `spreadsheet` is out of the capability table entirely, and import/export became `feat:import_export`, one more token in the same opt-in set as the other feature areas. Which capabilities an add-on bundles is decided where keys are minted; a key may still carry the word, and this library now reads it the way it reads any word it does not know. The static getRegisteredFunctionNames is no longer marked deprecated. That decision was never taken, so the tag had no business being here. The method documentation stays, since it explains a real difference between the two forms. The changelog collapses to one entry under Added, written for someone using the library rather than reviewing it. isLicenseGateActive is removed. It short-circuited gate B for an unrestricted entitlement, which the two-axis capability shape already does in one comparison at each of the four call sites. Measured on 12000 function calls per recalculation, nine interleaved A/B pairs: median -1.3% with the flag removed, 5 ms apart on the minima of a 180 ms run. It was not paying for the fourth way to express the same condition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An adversarial line-by-line pass found it, and a mutation survived on it: `feat:import_export` is recognized, so naming it switches the opt-in fallback off like any other feature token. A key whose only feature token is this one — a token that gates nothing today — loses CRUD, undo, clipboard, named expressions and batching. Reproduced on a minted key: the same key spelling the old `import_export` word keeps all six areas, spelling `feat:import_export` keeps one. That is the ratified opt-in rule working, not a defect, but it puts a contract on the issuing side that nothing stated: once a key names this token it must name every other area the customer bought. It is now on the token's own documentation and pinned by a test that mints such a key and asserts the four guards throw. Before this the exclusion of that token from the fallback trigger could be deleted with the whole suite staying green. Also from the same pass: the capability table's vocabulary list still advertised two add-on tokens it no longer has; three comments still said "five" feature areas where there are now six; the static getRegisteredFunctionNames still deprecated by prose after its tag was removed, and now describes the difference between the two forms without telling anyone to migrate; and the changelog entry names the one existing method whose output this narrows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What @sequba asked for on 28.09. `isIsoDate` no longer type-checks the date; it stringifies exactly as upstream does, so this file is a verbatim port again and a drift review can compare it byte for byte instead of reasoning about a patch. The check moved into the code this repository owns: `hyperformulaDateFieldIsWellTyped` in licenseResolution.ts, applied where a malformed payload already takes the invalid-key path. Measured, the verdicts a customer can observe are unchanged — a string date resolves valid, an array date resolves invalid either way; a number or an object never reached the type check because the reader rejects their shape anyway. One behaviour DID change and it is deliberate: only HyperFormula's own entry is type-checked now. A malformed date in another product's entry used to invalidate the whole key and no longer does. Another product's fields are not ours to validate, and failing closed there would take down a customer whose HyperFormula grant is intact. Also restores the exports the capabilities refactor made module-private. FUNCTION_GROUPS and UNGROUPED_FUNCTIONS are what the paired suite's drift checks read to compare this transcription against the packaging document group by group; they had stopped running silently, throwing on `undefined` rather than failing an assertion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An adversarial review of this branch reproduced both claims and neither holds. RETRACTION: "which silently stopped running when these became module-private". Measured on a cold compile, removing both exports gives TS2459 and the paired suite reports ZERO tests — a hard red, not a silence. The original observation behind that wording, `Cannot read properties of undefined`, was a stale ts-jest cache artefact. The same word went into the review comment that asked for these exports, so the premise there was overstated too; the ruling it produced stands either way, and the comment now says what a reproduction actually shows. The JSDoc fifteen lines above the export still opened with "Module-private: the groups reach the outside only as entries of FUNCTION_CAPABILITY_TABLE, which is where a test has to read them from" — the exact sentence the export overturned, shipped in a public file and contradicting the line below it. UNGROUPED_FUNCTIONS goes back to module-private. The ruling was conditional — "if they are needed for tests, you can export them" — and nothing imports this one; only FUNCTION_GROUPS is read by the drift checks. Widening a module's surface for no caller is the kind of thing that later reads as intent. tsc clean; full suite 517/517, 6551 passed, 3 skipped, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ediately found drift What @sequba asked for on the PR, and what we agreed at the 18.09 review call: a machine-readable pin beside the prose, and a CI guard that fails when upstream moves. "Oprócz tego zrobiłbym tu jakiś taki plik automatyczny... w formacie json, żeby potem CI to mogło łatwo czytać." upstream.lock.json is the pin: repository, tracked branch, commit, and a sha256 per upstream file. PROVENANCE.md keeps the prose and loses the hash table, which is what the call asked for - a markdown that says what this is, and a separate file for the machine. The first version of the script fetched each file AT THE PINNED COMMIT. That is close to tautological: a commit is immutable, so it would have passed forever. It now fetches from the tracked branch - `master`, the released code, since `develop` there is work in progress - and compares against the pin. First run: DRIFT utils.js: master is 8fba0566…, the pin says 135a8396… Upstream `6862da557` (2026-09-28, DEV-3031) changed `parseIsoDate` to test `typeof isoDate === 'string'` before the pattern. That is the divergence this directory used to carry and that we removed on 28.09 when moving to the verbatim port; upstream adopted the same fix the same day. PROVENANCE.md said "upstream has not adopted it (checked at c50ef40a, develop and master; no pull request or issue proposes it)" - true when written, false within forty-eight hours, and nothing would have told us. That is the whole case for the guard. Not re-ported here, because it is a behaviour decision rather than a mechanical one: upstream's check invalidates the whole key when ANY product's date is mistyped, while hyperformulaDateFieldIsWellTyped deliberately only does so for HyperFormula's own entry. Raised on the PR. Without credentials the check FAILS rather than skipping. "Could not verify" is not "verified", and a guard that reports success when it could not look is worse than no guard. Verified by breaking it five ways: a wrong hash, no credentials, an unpinned file in the directory, a pinned file missing, and the live drift above. tsc clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…o upstream @sequba asked three times for this and I had delivered half of it. 22.09: "Can we add exceptions to tsconfig so these files could be the byte-identical copies of the upstream?" 23.09: "The goal is (b)" — literally no local edits. 28.09: "let's implement option 1." What landed on 29.09 was only the guard move; the files stayed TypeScript ports. This morning's question — "so, now all the files inside handsontable-license-key-parser/ are byte-identical copies of upstream?" — had the answer "no", and the drift check I wrote first was a workaround for that. Now the answer is yes, and `npm run check:vendored-parser` says so by measurement: vendored parser: handsontable/license-key/src/entitlement-key @ master, 5 files ok constants.js detect-format.js extract-key-data.js utils.js sha512.js OK - every file is byte-identical to master The five `.ts` ports are replaced by upstream's `.js`, copied without a character changed, at `03810fd3c` on `master`. A hand-written `.d.ts` sits beside each one. `allowJs` is on, which is what he proposed on 22.09. The check now does what he asked for literally: it hashes OUR files, fetches the same files from the tracked branch, and compares. `upstream.lock.json` is gone. It stored a hash table, and he said we do not need to store hashes — true the moment the files are copies, because then there is nothing to store a hash of that is not already the file. `upstream.json` keeps only where the copy came from. PROVENANCE.md loses the section explaining why byte-identity was impossible and drops from 118 lines to 71. One behaviour change, deliberate, because verbatim does not get to pick. Upstream adopted the date type guard on 2026-09-28 (`6862da557`), the day before we removed ours on his instruction. Taking the file whole brings it back, which makes `hyperformulaDateFieldIsWellTyped` dead code — measured, not assumed: the same four tests fail with it and without it. Removed. Ours checked HyperFormula's own product entry only, on the grounds that another product's fields were not ours to reject. Upstream's lives in the parser and applies to every entry, so a mistyped date in a DataGrid entry now invalidates the whole key, our grant included. That is upstream's rule and the key spec's (T7). Four tests are rewritten to it, with the reversal named in a comment so the next reader meets it in the suite rather than in a support ticket. Two mechanical consequences of shipping JavaScript from `src/`: - Jest transformed only `.ts`, so the ES modules went to Node raw and every licence suite died on the first `export`. The transform now covers `.js`. - ESLint produced 216 errors, all of them upstream's semicolons. `--fix` would edit the files, which is the one thing they must not have, so the directory joins `3rdparty` in `.eslintignore`. The `.d.ts` beside them are ours and stay linted. Verified: full suite 517/517 · 6551 passed · 3 skipped · 0 failed; `tsc --noEmit` clean; `npm run lint` on `src` 0 errors; `npm run bundle-all` green — four UMD bundles, commonjs and the publish-package check. The drift check was broken three ways on purpose and caught each: a local edit, an unaccounted `.js`, a missing file. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ckage's own parser The 22.09 / 23.09 / 28.09 rulings asked for byte-identical copies of upstream with no local edits. The previous commit delivered that against the wrong directory: `src/entitlement-key/*.js` is license-key's internal parser. Its own CLAUDE.md says what products copy - "They copy vendor/entitlement-key-reader/ - a general, dependency-free TypeScript reader" - and that directory appeared upstream on 2026-09-28 13:22Z, two hours after the ruling, so neither side could have pointed at it at the time. This commit copies that directory whole, at tag 4.0.1, as its README prescribes. Everything the .js detour needed goes with it: allowJs, five hand-written .d.ts, the Jest transform for .js, the lint exemption for .js, the stored-hash pin. Two independent reviews had measured that the .js route shipped typings generated from upstream's JSDoc - EntitlementKeyData and LicenseKeyFormat gone from the published contract, parseIsoDate declared as (string) while the change was about accepting non-strings, and the Handsoncode licence header missing from five shipped files. With TypeScript sources the published typings come from the code: `extractEntitlementKeyData(licenseKey: string): EntitlementKeyData | null`, `parseIsoDateToTimestamp(isoDate: unknown): number | null`. One declared divergence, and only one - so "no local edits" is one line short. HyperFormula compiles with TypeScript 4.0.8; upstream targets ^5.9.3, and 4.0.8 does not narrow `unknown` through `!== null && typeof === 'object'`. One cast on one line of extractKeyData.ts makes the directory compile here (measured: the same files type-check clean under 5.9.3, and the un-cast line starts compiling at 4.5 - 4.4.4 fails, 4.5.5 passes). The cast is recorded in upstream.json with its reason and its expiry; the drift check substitutes it before comparing. Tags are immutable, so the check cannot see upstream fix the line on a branch; it fails on the next tag, and re-taking the copy there makes the entry stop matching, so the shim comes out rather than riding along. The alternatives - wait for a license-key release carrying the cast, or move HyperFormula to TypeScript >= 4.5 - are put to the reviewer; HyperFormula has no push access to license-key, so the upstream fix has to be requested, not made. The drift check is now upstream's own `diff -r`, done through the API: every file of the upstream directory at the pinned tag, byte for byte, plus a check that the tag still points at the pinned commit, plus what a `diff -r` would not tell you - a local file upstream does not have fails the check, because a `foo.ts` beside a copied `foo.ts` wins module resolution silently (an earlier version printed "byte-identical" while exactly that shadow flipped a key from INVALID to VALID). A newer upstream tag fails it too. A 401/403/404 is reported as an access problem, not as drift. Broken eight ways on purpose, caught eight times. It also runs in CI now, which an earlier commit message claimed and no workflow did: vendored-parser.yml, on pull requests that touch the directory and on pushes to master and develop, plus a weekday cron - which GitHub runs on the default branch only, so it watches releases, not open PRs. It needs LICENSE_KEY_REPO_TOKEN as a repository secret and is red until that exists - deliberately, and visibly on this PR from the first push. Engine-side consumers change by name only: detectFormat, extractKeyData, encoding (parseIsoDateToTimestamp for parseIsoDate), types (ProductEntitlement for EntitlementProductGrant), CHECKSUM_LENGTH. detectLicenseKeyFormat now takes the literal keys from the caller and answers 'literal'; HyperFormula never passed any and only asks "is this an entitlement key", so its routing is unchanged. The reader's own date-type check made hyperformulaDateFieldIsWellTyped dead code, so it is removed - which reverses the 28.09 instruction to move the guard into licenseTermsOf and the HF-entry-only narrowing described on 29.09: a mistyped date in another product's entry now invalidates the whole key. A verbatim copy does not get to pick; that is upstream's rule and the key spec's (T7), and it is pinned. The reader also carries window evaluation and grant picking (readEntitlementLicense, classifyEntitlement, getLicenseGrants). HyperFormula keeps evaluating windows in licenseResolution.ts and does not use those yet; consolidating onto them is a separate decision, not taken here. tsc clean; full suite 517/517, 6549 passed, 3 skipped, 0 failed; lint on src 0 errors; bundle-all green with the reader present in every bundle; drift check OK. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ommit A review of the previous commit found two things it said that its code did not do. "The check fails the moment upstream touches that line" - it cannot. It compares against an immutable tag, so an upstream fix on a branch never reaches it. What it sees is the next tag, and re-taking the copy there is what makes the shim entry stop matching. Reworded in the script header, the pin's comment and PROVENANCE. `pin.commit` was decorative - nothing read it, so a moved tag or an edited pin passed silently, the opposite of the commit tracking agreed on 18.09. The check now asserts that the pinned tag resolves to the pinned commit, and fails if the tag is gone or points elsewhere. Verified by editing the commit in the pin. The TypeScript floor in the divergence's expiry was wrong: measured, the un-cast line fails under 4.4.4 and compiles under 4.5.5, so it reads ">= 4.5", not 4.9. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…res its permissions Two things CI said about 71a0af6. codecov/patch fell to 78%: 149 uncovered lines, all inside the vendored reader, mostly in the modules HyperFormula does not call yet (classify, readLicense, grants, buildDate, index). That directory is a byte-identical copy of upstream's vendor/entitlement-key-reader - upstream tests it, and this repository may not edit it - so its coverage measures nothing about HyperFormula. codecov now ignores it for the patch and project statuses. The local jest report is left alone on purpose, so the numbers are still there for anyone who wants them. CodeQL: "Workflow does not contain permissions". Every other workflow here sets `permissions: contents: read`; vendored-parser.yml now does too. The job only reads the repository and talks to a different one with its own token. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… access error Review found the realistic failure - a real tag with a commit that does not match it - blamed access: the pin pointed at 4.0.0, the reader directory does not exist at 4.0.0, the listing 404'd, and the check stopped there with "the token has no access to it, or the tag does not exist", both false. The tag-to-commit assertion ran after the listing, so it never spoke. It now runs first, the 404 message names the third cause (the directory absent at that ref), and the newer-tag rule is skipped when the pin itself is not a release tag, which used to list every tag as "newer" than `master`. The same review found one sentence, in three places, promising more than the code does: "a shim cannot outlive the tag it was written against". It can - a newer tag that leaves the line alone re-takes cleanly with the same cast. What the check does guarantee is that an upstream fix of the line cannot be missed, because the swap stops matching at that re-take. The three places now say that, and PROVENANCE's "Checking it" lists the tag-to-commit check it had left out. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ck found first The previous commit moved the tag-to-commit assertion ahead of the listing and said the mismatch is now named. It was collected, not printed: the listing's failure exited before the problem list was shown, so the output still blamed access. Verified on the realistic case - pin at 4.0.0 with the 4.0.1 commit - which now prints the mismatch before the 404. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Kuba Sekowski <sequba@users.noreply.github.com>
…ream prescribes The vendored reader's root README and its own guide both say a product calls `readEntitlementLicense` and keeps beside the copy only what is product-specific. HyperFormula used the reader's parsing pieces and re-implemented the lifecycle on top of them. That layer is gone: - the reader verifies the key, picks the `hyperformula` entry, places it in its lifecycle window and reads its flags; - `licenseResolution.ts` maps the ten lifecycle states onto gate A (the soft-stop states stay VALID and quiet, as decision D5-A builds only the notice and the hard stop), and keeps the capability-token meaning and the console messages. Two observable changes, both towards what every other product reading the same key does: - a key granting other products but not HyperFormula is INVALID (`product_missing`) and restricts nothing; it used to be VALID with an empty grant; - only `no-console-warns` silences the console; `silent-console` and `silent` are unknown flags now. The generator emits neither. Measured: tsc 0, lint 0, drift check OK, 517/517 suites (6543 passed, 3 skipped), bundle-all OK. Three mutations of the state mapping (soft stop as expired, notice never printed, release_expired as valid) each fail the license suite (1, 8 and 5 tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- LicenseEntitlement: drop "until PR 3 lands" (it landed); `unrestricted` also covers missing, invalid and expired keys. - licenseResolution: D5-A keeps hard blocking and defers the windows; the notice was built anyway. The old wording attributed the notice to D5-A. Comments only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ruling on #1728 (2026-10-01), for a non-trial hard stop and release_expired: "Don't block. It should just print an error in console." That matches the reader's guide ("18.1 never blocks a paying customer") and rev 5 §4.1. - resolveLicense now returns `blocksEvaluation` beside `validityState`. The state stays EXPIRED, so VERSION() and the message still say so; gate A in the interpreter reads the new flag instead of the state. - usage_hard_stop and release_expired: EXPIRED, not blocking, the message goes to console.error, and the key keeps its own grants (the reader reports it as licensed, so it never gains more on expiry). - trial_hard_stop, invalid, missing and every classic 25-character key block exactly as before. - docs/guide/license-key.md says which expired keys still give #LIC!. Measured: tsc 0, lint 0, 517/517 suites (6550 passed, 3 skipped), bundle-all OK. Paired tests: hyperformula-tests 107ea9d. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… expiry Bugbot (Low) on 85dc976: the public guide should not describe the anatomy of proprietary key formats. The paragraph now says an expired key either behaves like an invalid one or keeps working with what it grants and prints a console error, depending on the license terms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as Handsontable does
Compared with Handsontable's implementation of the same reader
(handsontable/src/helpers/mixed.ts), HyperFormula disagreed about one key
in two ways: it printed a day rebuilt from the grace period
(usage_until + 1 + grace, "February 1, 2020") where Handsontable prints
the key's own date ("2020-01-01 (UTC)"), and it said nothing during the
grace period where Handsontable prints an error.
- licenseKeyValidator: a per-state table (severity + text) transcribed
from the specification, the same rows as Handsontable's, chosen by the
reader's lifecycle state. Warnings while the key works, errors once it
has run out, including the grace period. release_expired names the
installed version and the downgrade clause. Once per key per page.
- Classic 25-character keys keep their messages and their single
once-per-page flag byte for byte.
- Removed the rebuilt-date path (firstUncoveredDay), the separate notice
function and the usage/release split of the classic expired message.
- PROVENANCE.md records the owner's ruling on the one declared cast
("Neither. Let's keep this solution for now.", 2026-10-01).
Measured: tsc 0, lint 0, drift check OK, 517/517 suites (6557 passed,
3 skipped), bundle-all OK. Paired tests: hyperformula-tests (this push).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Bugbot (Medium) on e929f23: keying the once-per-page set by the key alone dropped a later, different message for the same key, so a page that stayed open while a key moved from its notice window into expiry never printed the expiry error. The set now holds key + message text, which is what the specification asks ("each distinct message once per key per page"). A soft stop and a hard stop share one text, so it does not repeat. Measured: tsc 0, lint 0, full suite green. Paired test in hyperformula-tests. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Remove ticket, pull request, task, review-date and decision references, internal specification section numbers and a personal name from comments in the license code. The reasons they carried are kept in plain terms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s are gated The comment said function availability is not license-gated and pointed at a future change. Function listing is gated by the key now, so the comment stated the opposite of the behaviour and carried ticket ids. It now says why the page is generated with a fully entitled key. Comment only. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
license-key 5.0.0 changes the key: the checksum now covers its sentences as well as its payload, a block on its own is no longer a key, and "4.x keys are no longer valid; there is no compatibility path". The reader copied here was 4.0.1, so a key issued by a 5.0.0 generator would read as invalid and block evaluation, and a key from a 4.x generator is invalid for 5.0.0. The copy has to match the generator that issues the keys. - The 13 files of `vendor/entitlement-key-reader` are re-taken from the tag; five differ from 4.0.1 (extractKeyData, readLicense, types and the two guides). - The one declared divergence is unchanged: the same `Object.keys(current as object)` cast, on the same line of text (now line 146), for TypeScript 4.0.8. `upstream.json` and PROVENANCE.md carry the new tag, commit and date. - A comment on the key identity no longer says a bare block is the same license. - No engine logic changes. `npm run check:license-key-parser-drift` reports the directory identical to the tag apart from the declared cast. tsc and lint are clean, 512 suites (6413 tests) pass and the bundles build. Paired tests: same branch name in hyperformula-tests. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
hyperformula-docs | 5b72a24 | Commit Preview URL Branch Preview URL |
Oct 06 2026, 07:22 AM |
Performance comparison of head (5b72a24) vs base (b0ee8e4) |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## develop #1794 +/- ##
===========================================
- Coverage 97.32% 97.31% -0.01%
===========================================
Files 195 201 +6
Lines 15739 15953 +214
Branches 3461 3533 +72
===========================================
+ Hits 15318 15525 +207
- Misses 413 420 +7
Partials 8 8
🚀 New features to boost your workflow:
|
This branch has not been deployed
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.
Takes
vendor/entitlement-key-readerfromlicense-keytag 5.0.0. The copy was 4.0.1.Why this is its own PR
#1728 stands without it. This only matters if keys will be issued by a 5.0.0 generator. It is based on the #1728 branch, so it shows one commit; when #1728 merges, retarget it to
develop.What 5.0.0 changes for a product that copies the reader
So with the 4.0.1 copy a key from a 5.0.0 generator reads as invalid and every cell evaluates to
#LIC!, and a key from a 4.x generator is invalid for 5.0.0. The copy has to match the generator that issues the keys.What changes here
extractKeyData.ts,readLicense.ts,types.tsand the two guides.Object.keys(current as object)cast for TypeScript 4.0.8, on the same line of text (now line 146).upstream.jsonandPROVENANCE.mdcarry the new tag, commit and date.Checked
npm run check:license-key-parser-drift: the directory is identical to the tag apart from the declared cast.tscand lint clean; 512 suites (6413 tests) pass; the bundles build.hyperformula-tests. They do not compile against the 4.0.1 copy.Open
Note
Medium Risk
Breaking entitlement-key format with no backward compatibility; merging before the key generator is on 5.0.0 would invalidate customer keys or reject 4.x keys.
Overview
Re-vendors the entitlement-key reader from
handsontable/license-key4.0.1 → 5.0.0, withupstream.json/PROVENANCE.mdupdated and the same single TypeScript 4.0.8 cast inextractKeyData.ts.The 5.0.0 format change (DEV-3253) is the substance: the SHA-512 checksum is now over canonical prose + payload, not the payload alone.
extractKeyData.tsaddscanonicalizeProse,computeChecksum, rejects empty prose, bare[...]blocks, and non-whitespace after the block;types.ts/ docs expandunreadableaccordingly. Integrators must pass the full key (no trimming inside the block); README gains “Where users keep the key” and stricter AGENTS.md rules.HyperFormula engine wiring is unchanged; only a
keyIdentityOfcomment inlicenseKeyValidator.tsreflects that identity still uses the trailing checksum. 4.x keys and 5.0.0 keys are not interchangeable—this copy must match whichever generator issues production keys.Reviewed by Cursor Bugbot for commit 5b72a24. Bugbot is set up for automated code reviews on this repo. Configure here.