@@ -44,6 +44,11 @@ export const FUNCTIONS_2_TOKEN = 'functions_2'
4444export const FUNCTIONS_3_TOKEN = 'functions_3'
4545/** Excel simulator package — the entire implemented catalog. */
4646export const FUNCTIONS_4_TOKEN = 'functions_4'
47+ /**
48+ * The packaging doc's whole-catalog token — the excel-simulator package as that doc's own
49+ * vocabulary spells it. Grants exactly what {@link FUNCTIONS_4_TOKEN} grants.
50+ */
51+ export const FUN_ALL_TOKEN = 'fun:all'
4752/**
4853 * Spreadsheet Bundle add-on (2026-08-12 packages meeting). Grants {@link FeatureId.Crud},
4954 * {@link FeatureId.UndoRedo}, {@link FeatureId.Clipboard} and {@link FeatureId.Batching} — see
@@ -57,6 +62,21 @@ export const SPREADSHEET_ADDON_TOKEN = 'spreadsheet'
5762 */
5863export const IMPORT_EXPORT_ADDON_TOKEN = 'import_export'
5964
65+ /**
66+ * The canonical spelling of a capability token for table lookups.
67+ *
68+ * Token names are case-insensitive — the packaging doc states it outright for its `fun:*`
69+ * vocabulary, and tolerating case on the other tokens costs nothing since none of them collide
70+ * under lowercasing. Normalization happens at LOOKUP, never at storage: an entitlement carries
71+ * the key's own spellings (they are diagnostics), and {@link CAPABILITY_TABLE} is keyed by the
72+ * lowercase form.
73+ *
74+ * @param {string } token - a capability token as the key spells it
75+ */
76+ export function normalizeCapabilityToken ( token : string ) : string {
77+ return token . toLowerCase ( )
78+ }
79+
6080/**
6181 * Describes what a capability token grants: a set of function ids, a set of {@link FeatureId}
6282 * values, and optionally other tokens it implies. `implies` is expanded recursively by
@@ -78,58 +98,120 @@ const OPERATOR_FUNCTIONS = [
7898 'HF.MULTIPLY' , 'HF.NE' , 'HF.POW' , 'HF.UMINUS' , 'HF.UNARY_PERCENT' , 'HF.UPLUS' ,
7999]
80100
101+ /**
102+ * The two protected built-ins. Both are named by the packaging doc (`fun:lookup.A`, `fun:info.A`)
103+ * but sit OUTSIDE the token system today — the interpreter never gate-checks a protected
104+ * function, so granting them would be dead weight that implies a restriction that does not exist.
105+ * The doc calls this a "technical limitation" on both; their tokens below are recognized but
106+ * grant nothing.
107+ */
108+ const PROTECTED_BUILT_INS = [ 'OFFSET' , 'VERSION' ]
109+
81110// An earlier revision granted all five features from CORE_TOKEN, which made feature gating inert
82111// by construction: no restricted key could ever lose an API area. Kuba's call (task comment, 12.08):
83112// "Feature gating should work, but the legacy keys should grant all feat:* capabilities" — legacy
84113// keys already resolve to the unrestricted entitlement, so the carve-out costs nothing, and the
85114// five features moved onto their own `feat:*` tokens below.
86115
87116/**
88- * Package membership, as the LOWEST package that includes each function.
117+ * The 21 function groups of the packaging doc, keyed by their group tokens in normalized
118+ * (lowercase) spelling — the doc writes them `fun:<family>.<A|B|C>` and declares all token names
119+ * case-insensitive.
89120 *
90- * Transcribed from Kuba's 12.08 packaging doc (CU doc `8cnjcyf-33175`, page `8cnjcyf-47835`, "HF
91- * function groups and packages"), which supersedes the earlier evidence file this table was first
92- * built from. The doc organizes the catalog into 21 group tokens (`fun:<family>.<A|B|C>` ) and
93- * states each package as the cumulative union of specific groups: Math engine = the `.A` groups,
94- * Calculated fields = `.A` + `.B`, Spreadsheet = `.A` + `.B` + `.C`. Reproducing that union gives
95- * 17 / 64 / 161 cumulative functions before the two protected built-ins below are removed;
96- * `capability-table.spec.ts` pins the resulting counts so a later edit cannot drift from them
97- * silently .
121+ * Transcribed 1:1 from §6 of Kuba's 12.08 packaging doc (CU doc `8cnjcyf-33175`, page
122+ * `8cnjcyf-47835`, "HF function groups and packages"), INCLUDING the members that resolve to no
123+ * grant here: the operators (granted by { @link CORE_TOKEN} instead ) and the protected built-ins
124+ * (outside the token system, see { @link PROTECTED_BUILT_INS}). Keeping the doc's own membership
125+ * verbatim is what makes this map the SINGLE SOURCE OF TRUTH both token dialects read from — the
126+ * package slices below are DERIVED from these groups, so moving a function between groups moves
127+ * it in both dialects at once, and `capability-table.spec.ts` pins each group's size against the
128+ * doc's published counts so a re-transcription is a reviewable diff .
98129 *
99- * `OFFSET` and `VERSION` are named by the doc (as `fun:lookup.A` and `fun:info.A`) but are
100- * deliberately absent from every list below: both are protected built-ins that sit OUTSIDE the
101- * token system today — the interpreter never gate-checks a protected function, so listing them
102- * would be dead weight that implies a restriction that does not exist. The doc calls this a
103- * "technical limitation" on both; see `hf-306-token-vocabulary-final` for the root cause of each
104- * (registry protection for VERSION, parse-time resolution for OFFSET) and what closing it would
105- * take.
106- */
107- const MATH_ENGINE_FUNCTIONS = [
108- 'ABS' , 'AVERAGE' , 'COUNT' , 'IF' , 'LOG' , 'MAX' , 'MIN' , 'MOD' , 'POWER' , 'PRODUCT' , 'ROUND' , 'ROUNDDOWN' ,
109- 'ROUNDUP' , 'SQRT' , 'SUM' ,
130+ * The doc freezes group names as API surface: once shipped inside license keys, a rename is a
131+ * breaking change.
132+ */
133+ export const FUNCTION_GROUPS : ReadonlyMap < string , readonly string [ ] > = new Map ( [
134+ [ 'fun:math.a' , [ 'ABS' , 'LOG' , 'MOD' , 'POWER' , 'PRODUCT' , 'ROUND' , 'ROUNDDOWN' , 'ROUNDUP' , 'SQRT' , 'SUM' ] ] ,
135+ [ 'fun:stat.a' , [ 'AVERAGE' , 'COUNT' , 'MAX' , 'MIN' ] ] ,
136+ [ 'fun:logic.a' , [ 'IF' ] ] ,
137+ [ 'fun:operator.a' , [ ...OPERATOR_FUNCTIONS ] ] ,
138+ [ 'fun:info.a' , [ 'VERSION' ] ] ,
139+ [ 'fun:lookup.a' , [ 'OFFSET' ] ] ,
140+ [ 'fun:time.b' , [
141+ 'DATE' , 'DATEDIF' , 'DATEVALUE' , 'DAY' , 'DAYS' , 'EOMONTH' , 'HOUR' , 'ISOWEEKNUM' , 'MINUTE' , 'MONTH' ,
142+ 'NETWORKDAYS' , 'SECOND' , 'TODAY' , 'WEEKDAY' , 'WEEKNUM' , 'WORKDAY' , 'YEAR' ,
143+ ] ] ,
144+ [ 'fun:text.b' , [
145+ 'CONCATENATE' , 'EXACT' , 'LEFT' , 'LEN' , 'LOWER' , 'MID' , 'REPLACE' , 'REPT' , 'RIGHT' , 'SEARCH' ,
146+ 'SUBSTITUTE' , 'TEXT' , 'TRIM' , 'UPPER' , 'VALUE' ,
147+ ] ] ,
148+ [ 'fun:logic.b' , [ 'AND' , 'FALSE' , 'IFS' , 'NOT' , 'OR' , 'SWITCH' , 'TRUE' , 'XOR' ] ] ,
149+ [ 'fun:math.b' , [ 'RAND' , 'RANDBETWEEN' , 'SUMIF' , 'SUMIFS' ] ] ,
150+ [ 'fun:stat.b' , [ 'AVERAGEIF' , 'COUNTIF' , 'STDEV.S' ] ] ,
151+ [ 'fun:lookup.c' , [
152+ 'ADDRESS' , 'CHOOSE' , 'COLUMN' , 'COLUMNS' , 'FILTER' , 'HLOOKUP' , 'HSTACK' , 'HYPERLINK' , 'INDEX' , 'MATCH' ,
153+ 'ROW' , 'ROWS' , 'SORT' , 'TRANSPOSE' , 'UNIQUE' , 'VLOOKUP' , 'VSTACK' , 'XLOOKUP' ,
154+ ] ] ,
155+ [ 'fun:math.c' , [
156+ 'ACOS' , 'ASIN' , 'ATAN' , 'ATAN2' , 'CEILING' , 'COS' , 'EVEN' , 'EXP' , 'FLOOR' , 'INT' , 'LN' , 'MROUND' , 'ODD' ,
157+ 'PI' , 'QUOTIENT' , 'SEQUENCE' , 'SIGN' , 'SIN' , 'SUBTOTAL' , 'SUMPRODUCT' , 'SUMSQ' , 'SUMXMY2' , 'TAN' ,
158+ ] ] ,
159+ [ 'fun:stat.c' , [
160+ 'AVERAGEA' , 'COUNTA' , 'COUNTBLANK' , 'COUNTIFS' , 'LARGE' , 'MAXIFS' , 'MEDIAN' , 'MINIFS' , 'PERCENTILE.INC' ,
161+ 'SMALL' , 'STDEV.P' , 'STDEVA' , 'STDEVPA' , 'VAR.P' , 'VAR.S' ,
162+ ] ] ,
163+ [ 'fun:time.c' , [ 'DAYS360' , 'EDATE' , 'NOW' , 'TIME' , 'YEARFRAC' ] ] ,
164+ [ 'fun:text.c' , [ 'CHAR' , 'CLEAN' , 'CODE' , 'FIND' , 'PROPER' , 'T' , 'TEXTJOIN' , 'UNICHAR' ] ] ,
165+ [ 'fun:info.c' , [
166+ 'ISBLANK' , 'ISERR' , 'ISERROR' , 'ISEVEN' , 'ISLOGICAL' , 'ISNA' , 'ISNUMBER' , 'ISODD' , 'ISTEXT' , 'N' , 'NA' ,
167+ ] ] ,
168+ [ 'fun:logic.c' , [ 'IFERROR' , 'IFNA' ] ] ,
169+ [ 'fun:finance.c' , [ 'FV' , 'IPMT' , 'IRR' , 'NPV' , 'PMT' , 'PPMT' , 'PV' , 'RATE' , 'SLN' , 'XIRR' , 'XNPV' ] ] ,
170+ [ 'fun:engineer.c' , [ 'DEC2HEX' , 'HEX2DEC' ] ] ,
171+ [ 'fun:array.c' , [ 'ARRAYFORMULA' , 'ARRAY_CONSTRAIN' ] ] ,
172+ ] )
173+
174+ /** The group tokens each package adds, exactly as the packaging doc's §4 table states them. */
175+ const MATH_ENGINE_GROUPS = [ 'fun:math.a' , 'fun:stat.a' , 'fun:logic.a' , 'fun:operator.a' , 'fun:info.a' , 'fun:lookup.a' ]
176+ const CALCULATED_FIELDS_GROUPS = [ 'fun:time.b' , 'fun:text.b' , 'fun:logic.b' , 'fun:math.b' , 'fun:stat.b' ]
177+ const SPREADSHEET_GROUPS = [
178+ 'fun:lookup.c' , 'fun:math.c' , 'fun:stat.c' , 'fun:time.c' , 'fun:text.c' , 'fun:info.c' , 'fun:logic.c' ,
179+ 'fun:finance.c' , 'fun:engineer.c' , 'fun:array.c' ,
110180]
111181
182+ /** The members of the given groups, concatenated. The groups are disjoint, so this is a union. */
183+ function membersOfGroups ( groupTokens : string [ ] ) : string [ ] {
184+ return groupTokens . reduce < string [ ] > (
185+ ( members , groupToken ) => members . concat ( FUNCTION_GROUPS . get ( groupToken ) ?? [ ] ) ,
186+ [ ] ,
187+ )
188+ }
189+
190+ /**
191+ * The members a group contributes to a package GRANT: the group verbatim, minus the operators
192+ * (granted by {@link CORE_TOKEN} in every package) and the protected built-ins (outside the token
193+ * system entirely).
194+ */
195+ function gatableMembersOfGroups ( groupTokens : string [ ] ) : string [ ] {
196+ return membersOfGroups ( groupTokens ) . filter (
197+ ( name ) => OPERATOR_FUNCTIONS . indexOf ( name ) === - 1 && PROTECTED_BUILT_INS . indexOf ( name ) === - 1 ,
198+ )
199+ }
200+
201+ /**
202+ * Package membership, DERIVED from {@link FUNCTION_GROUPS} as the cumulative group unions the
203+ * packaging doc's §4 table states: Math engine = the `.A` groups, Calculated fields = `.A` + `.B`,
204+ * Spreadsheet = `.A` + `.B` + `.C`. Reproducing that union gives 17 / 64 / 161 cumulative
205+ * functions before the two protected built-ins are removed; `capability-table.spec.ts` pins the
206+ * resulting full memberships by name so a re-derivation is a reviewable diff.
207+ */
208+ const MATH_ENGINE_FUNCTIONS = gatableMembersOfGroups ( MATH_ENGINE_GROUPS )
209+
112210/** Added by the calculated-fields package, on top of {@link MATH_ENGINE_FUNCTIONS}. */
113- const CALCULATED_FIELDS_FUNCTIONS = [
114- 'AND' , 'AVERAGEIF' , 'CONCATENATE' , 'COUNTIF' , 'DATE' , 'DATEDIF' , 'DATEVALUE' , 'DAY' , 'DAYS' , 'EOMONTH' ,
115- 'EXACT' , 'FALSE' , 'HOUR' , 'IFS' , 'ISOWEEKNUM' , 'LEFT' , 'LEN' , 'LOWER' , 'MID' , 'MINUTE' , 'MONTH' ,
116- 'NETWORKDAYS' , 'NOT' , 'OR' , 'RAND' , 'RANDBETWEEN' , 'REPLACE' , 'REPT' , 'RIGHT' , 'SEARCH' , 'SECOND' ,
117- 'STDEV.S' , 'SUBSTITUTE' , 'SUMIF' , 'SUMIFS' , 'SWITCH' , 'TEXT' , 'TODAY' , 'TRIM' , 'TRUE' , 'UPPER' , 'VALUE' ,
118- 'WEEKDAY' , 'WEEKNUM' , 'WORKDAY' , 'XOR' , 'YEAR' ,
119- ]
211+ const CALCULATED_FIELDS_FUNCTIONS = gatableMembersOfGroups ( CALCULATED_FIELDS_GROUPS )
120212
121213/** Added by the spreadsheet package, on top of {@link CALCULATED_FIELDS_FUNCTIONS}. */
122- const SPREADSHEET_FUNCTIONS = [
123- 'ACOS' , 'ADDRESS' , 'ARRAYFORMULA' , 'ARRAY_CONSTRAIN' , 'ASIN' , 'ATAN' , 'ATAN2' , 'AVERAGEA' , 'CEILING' ,
124- 'CHAR' , 'CHOOSE' , 'CLEAN' , 'CODE' , 'COLUMN' , 'COLUMNS' , 'COS' , 'COUNTA' , 'COUNTBLANK' , 'COUNTIFS' ,
125- 'DAYS360' , 'DEC2HEX' , 'EDATE' , 'EVEN' , 'EXP' , 'FILTER' , 'FIND' , 'FLOOR' , 'FV' , 'HEX2DEC' , 'HLOOKUP' ,
126- 'HSTACK' , 'HYPERLINK' , 'IFERROR' , 'IFNA' , 'INDEX' , 'INT' , 'IPMT' , 'IRR' , 'ISBLANK' , 'ISERR' , 'ISERROR' ,
127- 'ISEVEN' , 'ISLOGICAL' , 'ISNA' , 'ISNUMBER' , 'ISODD' , 'ISTEXT' , 'LARGE' , 'LN' , 'MATCH' , 'MAXIFS' , 'MEDIAN' ,
128- 'MINIFS' , 'MROUND' , 'N' , 'NA' , 'NOW' , 'NPV' , 'ODD' , 'PERCENTILE.INC' , 'PI' , 'PMT' , 'PPMT' , 'PROPER' , 'PV' ,
129- 'QUOTIENT' , 'RATE' , 'ROW' , 'ROWS' , 'SEQUENCE' , 'SIGN' , 'SIN' , 'SLN' , 'SMALL' , 'SORT' , 'STDEV.P' , 'STDEVA' ,
130- 'STDEVPA' , 'SUBTOTAL' , 'SUMPRODUCT' , 'SUMSQ' , 'SUMXMY2' , 'T' , 'TAN' , 'TEXTJOIN' , 'TIME' , 'TRANSPOSE' ,
131- 'UNICHAR' , 'UNIQUE' , 'VAR.P' , 'VAR.S' , 'VLOOKUP' , 'VSTACK' , 'XIRR' , 'XLOOKUP' , 'XNPV' , 'YEARFRAC' ,
132- ]
214+ const SPREADSHEET_FUNCTIONS = gatableMembersOfGroups ( SPREADSHEET_GROUPS )
133215
134216/**
135217 * Added by the excel-simulator package, on top of {@link SPREADSHEET_FUNCTIONS} — the rest of the
@@ -187,7 +269,50 @@ const functions4Grant: CapabilityGrant = {
187269}
188270
189271/**
190- * The production capability table.
272+ * One table entry per group token: the group's gatable members, so a key may assemble a package
273+ * from groups instead of naming a `functions_N` slice. `fun:info.a` and `fun:lookup.a` resolve to
274+ * EMPTY grants on purpose — their members are the protected built-ins, which are always available
275+ * and must never become table-covered (a covered function is gated for every key not granting
276+ * it). The tokens stay recognized either way, so a key carrying them is never reported as
277+ * unrecognized: they are the doc's bookkeeping identifiers for functionality every key gets.
278+ */
279+ const groupEntries : [ string , CapabilityGrant ] [ ] = Array . from ( FUNCTION_GROUPS . keys ( ) ) . map ( ( groupToken ) => [
280+ groupToken ,
281+ { functions : gatableMembersOfGroups ( [ groupToken ] ) , features : [ ] } ,
282+ ] )
283+
284+ /**
285+ * One table entry per canonical function name: the packaging doc's single-function tokens
286+ * (`fun:<CANONICAL_FUNCTION_NAME>`), "for surgical grants: custom deals, previews, per-function
287+ * exceptions". One exists for EVERY canonical name — including the operators (harmless: core
288+ * grants them anyway) and the protected built-ins (empty grants, as above). Alias names get no
289+ * token of their own: tokens reference canonical names, and an alias travels with its canonical
290+ * function because the gates canonicalize before consulting the table.
291+ */
292+ const singleFunctionEntries : [ string , CapabilityGrant ] [ ] = functions4Grant . functions
293+ . concat ( OPERATOR_FUNCTIONS , PROTECTED_BUILT_INS )
294+ . map ( ( name ) => [
295+ `fun:${ normalizeCapabilityToken ( name ) } ` ,
296+ { functions : PROTECTED_BUILT_INS . indexOf ( name ) === - 1 ? [ name ] : [ ] , features : [ ] } ,
297+ ] )
298+
299+ /**
300+ * The production capability table, keyed by NORMALIZED token spelling — look up through
301+ * {@link normalizeCapabilityToken}, never with a raw key string.
302+ *
303+ * The engine understands BOTH token dialects in circulation, resolved from the one group registry
304+ * above so they cannot drift apart:
305+ *
306+ * - the key spec's package slices (`functions_1..4`) plus the two add-on tokens — the vocabulary
307+ * the upstream generator's own schema mints today;
308+ * - the packaging doc's group vocabulary (`fun:all`, `fun:<family>.<A|B|C>`,
309+ * `fun:<CANONICAL_FUNCTION_NAME>`) — §6 of the 12.08 packaging doc.
310+ *
311+ * Accepting the superset is deliberate and spec-clean: an unrecognized token is defined as "a
312+ * grant this version does not implement" (strict-shape/lenient-vocabulary, T7), so implementing
313+ * more tokens than the generator currently mints breaks nothing — and it makes the engine robust
314+ * to the still-open business decision about which dialect keys will finally be worded in
315+ * (owner's call, 20.08). A key's function set is the UNION of everything recognized.
191316 *
192317 * The grants are stored FULLY EXPANDED rather than chained through `implies`: the packaging
193318 * design states the enforcement layer must not assume a hierarchy between tokens, and that the
@@ -212,6 +337,11 @@ const functions4Grant: CapabilityGrant = {
212337 * RESERVED grant, since nothing in the public API is gated on it yet: HF-107 hasn't shipped the
213338 * feature it would gate. Both tokens stay recognized either way, so an issued key carrying one is
214339 * never reported as unrecognized.
340+ *
341+ * Entry ORDER is load-bearing at one spot: `CapabilityRegistry`'s reverse index maps each
342+ * function id to the FIRST token that lists it, so the package slices stay ahead of the group and
343+ * single-function tokens, keeping `capabilityOf`'s answers what they were before the second
344+ * dialect existed.
215345 */
216346export const CAPABILITY_TABLE : ReadonlyMap < string , CapabilityGrant > = new Map ( [
217347 [ CORE_TOKEN , coreGrant ] ,
@@ -229,5 +359,7 @@ export const CAPABILITY_TABLE: ReadonlyMap<string, CapabilityGrant> = new Map([
229359 features : [ FeatureId . Crud , FeatureId . UndoRedo , FeatureId . Clipboard , FeatureId . Batching ] ,
230360 } ] ,
231361 [ IMPORT_EXPORT_ADDON_TOKEN , { functions : [ ] , features : [ FeatureId . ImportExport ] } ] ,
362+ [ FUN_ALL_TOKEN , { functions : [ ...functions4Grant . functions ] , features : [ ] } ] ,
363+ ...groupEntries ,
364+ ...singleFunctionEntries ,
232365] )
233-
0 commit comments