@@ -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,26 @@ 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. Surrounding whitespace is trimmed for a sharper reason than tidiness: every
71+ * rule that reads a token has to read the SAME token, and a padded one used to be read two
72+ * different ways at once — `' feat:crud'` failed the `feat:` prefix test that decides whether a key
73+ * speaks the feature vocabulary, so the key was granted all five feature areas instead of the one
74+ * it named, while `'feat:crud '` passed that test and then missed the table, granting none.
75+ *
76+ * Normalization happens at LOOKUP, never at storage: an entitlement carries the key's own
77+ * spellings (they are diagnostics), and {@link CAPABILITY_TABLE} is keyed by the normalized form.
78+ *
79+ * @param {string } token - a capability token as the key spells it
80+ */
81+ export function normalizeCapabilityToken ( token : string ) : string {
82+ return token . trim ( ) . toLowerCase ( )
83+ }
84+
6085/**
6186 * Describes what a capability token grants: a set of function ids, a set of {@link FeatureId}
6287 * values, and optionally other tokens it implies. `implies` is expanded recursively by
@@ -78,58 +103,120 @@ const OPERATOR_FUNCTIONS = [
78103 'HF.MULTIPLY' , 'HF.NE' , 'HF.POW' , 'HF.UMINUS' , 'HF.UNARY_PERCENT' , 'HF.UPLUS' ,
79104]
80105
106+ /**
107+ * The two protected built-ins. Both are named by the packaging doc (`fun:lookup.A`, `fun:info.A`)
108+ * but sit OUTSIDE the token system today — the interpreter never gate-checks a protected
109+ * function, so granting them would be dead weight that implies a restriction that does not exist.
110+ * The doc calls this a "technical limitation" on both; their tokens below are recognized but
111+ * grant nothing.
112+ */
113+ const PROTECTED_BUILT_INS = [ 'OFFSET' , 'VERSION' ]
114+
81115// An earlier revision granted all five features from CORE_TOKEN, which made feature gating inert
82116// by construction: no restricted key could ever lose an API area. The ratified rule:
83117// "Feature gating should work, but the legacy keys should grant all feat:* capabilities" — legacy
84118// keys already resolve to the unrestricted entitlement, so the carve-out costs nothing, and the
85119// five features moved onto their own `feat:*` tokens below.
86120
87121/**
88- * Package membership, as the LOWEST package that includes each function.
122+ * The 21 function groups of the packaging doc, keyed by their group tokens in normalized
123+ * (lowercase) spelling — the doc writes them `fun:<family>.<A|B|C>` and declares all token names
124+ * case-insensitive.
89125 *
90- * Transcribed from section 6 of the internal packaging design document ("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 .
126+ * Transcribed 1:1 from section 6 of the internal packaging design document ("HF function groups
127+ * and packages"), INCLUDING the members that resolve to no grant here: the operators (granted by
128+ * { @link CORE_TOKEN} instead) and the protected built-ins
129+ * (outside the token system, see { @link PROTECTED_BUILT_INS}). Keeping the doc's own membership
130+ * verbatim is what makes this map the SINGLE SOURCE OF TRUTH both token dialects read from — the
131+ * package slices below are DERIVED from these groups, so moving a function between groups moves
132+ * it in both dialects at once, and `capability-table.spec.ts` pins each group's size against the
133+ * doc's published counts so a re-transcription is a reviewable diff .
98134 *
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' ,
135+ * The doc freezes group names as API surface: once shipped inside license keys, a rename is a
136+ * breaking change.
137+ */
138+ export const FUNCTION_GROUPS : ReadonlyMap < string , readonly string [ ] > = new Map ( [
139+ [ 'fun:math.a' , [ 'ABS' , 'LOG' , 'MOD' , 'POWER' , 'PRODUCT' , 'ROUND' , 'ROUNDDOWN' , 'ROUNDUP' , 'SQRT' , 'SUM' ] ] ,
140+ [ 'fun:stat.a' , [ 'AVERAGE' , 'COUNT' , 'MAX' , 'MIN' ] ] ,
141+ [ 'fun:logic.a' , [ 'IF' ] ] ,
142+ [ 'fun:operator.a' , [ ...OPERATOR_FUNCTIONS ] ] ,
143+ [ 'fun:info.a' , [ 'VERSION' ] ] ,
144+ [ 'fun:lookup.a' , [ 'OFFSET' ] ] ,
145+ [ 'fun:time.b' , [
146+ 'DATE' , 'DATEDIF' , 'DATEVALUE' , 'DAY' , 'DAYS' , 'EOMONTH' , 'HOUR' , 'ISOWEEKNUM' , 'MINUTE' , 'MONTH' ,
147+ 'NETWORKDAYS' , 'SECOND' , 'TODAY' , 'WEEKDAY' , 'WEEKNUM' , 'WORKDAY' , 'YEAR' ,
148+ ] ] ,
149+ [ 'fun:text.b' , [
150+ 'CONCATENATE' , 'EXACT' , 'LEFT' , 'LEN' , 'LOWER' , 'MID' , 'REPLACE' , 'REPT' , 'RIGHT' , 'SEARCH' ,
151+ 'SUBSTITUTE' , 'TEXT' , 'TRIM' , 'UPPER' , 'VALUE' ,
152+ ] ] ,
153+ [ 'fun:logic.b' , [ 'AND' , 'FALSE' , 'IFS' , 'NOT' , 'OR' , 'SWITCH' , 'TRUE' , 'XOR' ] ] ,
154+ [ 'fun:math.b' , [ 'RAND' , 'RANDBETWEEN' , 'SUMIF' , 'SUMIFS' ] ] ,
155+ [ 'fun:stat.b' , [ 'AVERAGEIF' , 'COUNTIF' , 'STDEV.S' ] ] ,
156+ [ 'fun:lookup.c' , [
157+ 'ADDRESS' , 'CHOOSE' , 'COLUMN' , 'COLUMNS' , 'FILTER' , 'HLOOKUP' , 'HSTACK' , 'HYPERLINK' , 'INDEX' , 'MATCH' ,
158+ 'ROW' , 'ROWS' , 'SORT' , 'TRANSPOSE' , 'UNIQUE' , 'VLOOKUP' , 'VSTACK' , 'XLOOKUP' ,
159+ ] ] ,
160+ [ 'fun:math.c' , [
161+ 'ACOS' , 'ASIN' , 'ATAN' , 'ATAN2' , 'CEILING' , 'COS' , 'EVEN' , 'EXP' , 'FLOOR' , 'INT' , 'LN' , 'MROUND' , 'ODD' ,
162+ 'PI' , 'QUOTIENT' , 'SEQUENCE' , 'SIGN' , 'SIN' , 'SUBTOTAL' , 'SUMPRODUCT' , 'SUMSQ' , 'SUMXMY2' , 'TAN' ,
163+ ] ] ,
164+ [ 'fun:stat.c' , [
165+ 'AVERAGEA' , 'COUNTA' , 'COUNTBLANK' , 'COUNTIFS' , 'LARGE' , 'MAXIFS' , 'MEDIAN' , 'MINIFS' , 'PERCENTILE.INC' ,
166+ 'SMALL' , 'STDEV.P' , 'STDEVA' , 'STDEVPA' , 'VAR.P' , 'VAR.S' ,
167+ ] ] ,
168+ [ 'fun:time.c' , [ 'DAYS360' , 'EDATE' , 'NOW' , 'TIME' , 'YEARFRAC' ] ] ,
169+ [ 'fun:text.c' , [ 'CHAR' , 'CLEAN' , 'CODE' , 'FIND' , 'PROPER' , 'T' , 'TEXTJOIN' , 'UNICHAR' ] ] ,
170+ [ 'fun:info.c' , [
171+ 'ISBLANK' , 'ISERR' , 'ISERROR' , 'ISEVEN' , 'ISLOGICAL' , 'ISNA' , 'ISNUMBER' , 'ISODD' , 'ISTEXT' , 'N' , 'NA' ,
172+ ] ] ,
173+ [ 'fun:logic.c' , [ 'IFERROR' , 'IFNA' ] ] ,
174+ [ 'fun:finance.c' , [ 'FV' , 'IPMT' , 'IRR' , 'NPV' , 'PMT' , 'PPMT' , 'PV' , 'RATE' , 'SLN' , 'XIRR' , 'XNPV' ] ] ,
175+ [ 'fun:engineer.c' , [ 'DEC2HEX' , 'HEX2DEC' ] ] ,
176+ [ 'fun:array.c' , [ 'ARRAYFORMULA' , 'ARRAY_CONSTRAIN' ] ] ,
177+ ] )
178+
179+ /** The group tokens each package adds, exactly as the packaging doc's §4 table states them. */
180+ const MATH_ENGINE_GROUPS = [ 'fun:math.a' , 'fun:stat.a' , 'fun:logic.a' , 'fun:operator.a' , 'fun:info.a' , 'fun:lookup.a' ]
181+ const CALCULATED_FIELDS_GROUPS = [ 'fun:time.b' , 'fun:text.b' , 'fun:logic.b' , 'fun:math.b' , 'fun:stat.b' ]
182+ const SPREADSHEET_GROUPS = [
183+ 'fun:lookup.c' , 'fun:math.c' , 'fun:stat.c' , 'fun:time.c' , 'fun:text.c' , 'fun:info.c' , 'fun:logic.c' ,
184+ 'fun:finance.c' , 'fun:engineer.c' , 'fun:array.c' ,
110185]
111186
187+ /** The members of the given groups, concatenated. The groups are disjoint, so this is a union. */
188+ function membersOfGroups ( groupTokens : string [ ] ) : string [ ] {
189+ return groupTokens . reduce < string [ ] > (
190+ ( members , groupToken ) => members . concat ( FUNCTION_GROUPS . get ( groupToken ) ?? [ ] ) ,
191+ [ ] ,
192+ )
193+ }
194+
195+ /**
196+ * The members a group contributes to a package GRANT: the group verbatim, minus the operators
197+ * (granted by {@link CORE_TOKEN} in every package) and the protected built-ins (outside the token
198+ * system entirely).
199+ */
200+ function gatableMembersOfGroups ( groupTokens : string [ ] ) : string [ ] {
201+ return membersOfGroups ( groupTokens ) . filter (
202+ ( name ) => OPERATOR_FUNCTIONS . indexOf ( name ) === - 1 && PROTECTED_BUILT_INS . indexOf ( name ) === - 1 ,
203+ )
204+ }
205+
206+ /**
207+ * Package membership, DERIVED from {@link FUNCTION_GROUPS} as the cumulative group unions the
208+ * packaging doc's §4 table states: Math engine = the `.A` groups, Calculated fields = `.A` + `.B`,
209+ * Spreadsheet = `.A` + `.B` + `.C`. Reproducing that union gives 17 / 64 / 161 cumulative
210+ * functions before the two protected built-ins are removed; `capability-table.spec.ts` pins the
211+ * resulting full memberships by name so a re-derivation is a reviewable diff.
212+ */
213+ const MATH_ENGINE_FUNCTIONS = gatableMembersOfGroups ( MATH_ENGINE_GROUPS )
214+
112215/** 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- ]
216+ const CALCULATED_FIELDS_FUNCTIONS = gatableMembersOfGroups ( CALCULATED_FIELDS_GROUPS )
120217
121218/** 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- ]
219+ const SPREADSHEET_FUNCTIONS = gatableMembersOfGroups ( SPREADSHEET_GROUPS )
133220
134221/**
135222 * Added by the excel-simulator package, on top of {@link SPREADSHEET_FUNCTIONS} — the rest of the
@@ -187,7 +274,50 @@ const functions4Grant: CapabilityGrant = {
187274}
188275
189276/**
190- * The production capability table.
277+ * One table entry per group token: the group's gatable members, so a key may assemble a package
278+ * from groups instead of naming a `functions_N` slice. `fun:info.a` and `fun:lookup.a` resolve to
279+ * EMPTY grants on purpose — their members are the protected built-ins, which are always available
280+ * and must never become table-covered (a covered function is gated for every key not granting
281+ * it). The tokens stay recognized either way, so a key carrying them is never reported as
282+ * unrecognized: they are the doc's bookkeeping identifiers for functionality every key gets.
283+ */
284+ const groupEntries : [ string , CapabilityGrant ] [ ] = Array . from ( FUNCTION_GROUPS . keys ( ) ) . map ( ( groupToken ) => [
285+ groupToken ,
286+ { functions : gatableMembersOfGroups ( [ groupToken ] ) , features : [ ] } ,
287+ ] )
288+
289+ /**
290+ * One table entry per canonical function name: the packaging doc's single-function tokens
291+ * (`fun:<CANONICAL_FUNCTION_NAME>`), "for surgical grants: custom deals, previews, per-function
292+ * exceptions". One exists for EVERY canonical name — including the operators (harmless: core
293+ * grants them anyway) and the protected built-ins (empty grants, as above). Alias names get no
294+ * token of their own: tokens reference canonical names, and an alias travels with its canonical
295+ * function because the gates canonicalize before consulting the table.
296+ */
297+ const singleFunctionEntries : [ string , CapabilityGrant ] [ ] = functions4Grant . functions
298+ . concat ( OPERATOR_FUNCTIONS , PROTECTED_BUILT_INS )
299+ . map ( ( name ) => [
300+ `fun:${ normalizeCapabilityToken ( name ) } ` ,
301+ { functions : PROTECTED_BUILT_INS . indexOf ( name ) === - 1 ? [ name ] : [ ] , features : [ ] } ,
302+ ] )
303+
304+ /**
305+ * The production capability table, keyed by NORMALIZED token spelling — look up through
306+ * {@link normalizeCapabilityToken}, never with a raw key string.
307+ *
308+ * The engine understands BOTH token dialects in circulation, resolved from the one group registry
309+ * above so they cannot drift apart:
310+ *
311+ * - the key spec's package slices (`functions_1..4`) plus the two add-on tokens — the vocabulary
312+ * the upstream generator's own schema mints today;
313+ * - the packaging doc's group vocabulary (`fun:all`, `fun:<family>.<A|B|C>`,
314+ * `fun:<CANONICAL_FUNCTION_NAME>`) — §6 of the 12.08 packaging doc.
315+ *
316+ * Accepting the superset is deliberate and spec-clean: an unrecognized token is defined as "a
317+ * grant this version does not implement" (strict-shape/lenient-vocabulary, T7), so implementing
318+ * more tokens than the generator currently mints breaks nothing — and it makes the engine robust
319+ * to the still-open business decision about which dialect keys will finally be worded in
320+ * (owner's call, 20.08). A key's function set is the UNION of everything recognized.
191321 *
192322 * The grants are stored FULLY EXPANDED rather than chained through `implies`: the packaging
193323 * design states the enforcement layer must not assume a hierarchy between tokens, and that the
@@ -212,6 +342,11 @@ const functions4Grant: CapabilityGrant = {
212342 * RESERVED grant, since nothing in the public API is gated on it yet: HF-107 hasn't shipped the
213343 * feature it would gate. Both tokens stay recognized either way, so an issued key carrying one is
214344 * never reported as unrecognized.
345+ *
346+ * Entry ORDER is load-bearing at one spot: `CapabilityRegistry`'s reverse index maps each
347+ * function id to the FIRST token that lists it, so the package slices stay ahead of the group and
348+ * single-function tokens, keeping `capabilityOf`'s answers what they were before the second
349+ * dialect existed.
215350 */
216351export const CAPABILITY_TABLE : ReadonlyMap < string , CapabilityGrant > = new Map ( [
217352 [ CORE_TOKEN , coreGrant ] ,
@@ -233,5 +368,7 @@ export const CAPABILITY_TABLE: ReadonlyMap<string, CapabilityGrant> = new Map([
233368 features : [ FeatureId . Crud , FeatureId . UndoRedo , FeatureId . Clipboard , FeatureId . Batching ] ,
234369 } ] ,
235370 [ IMPORT_EXPORT_ADDON_TOKEN , { functions : [ ] , features : [ FeatureId . ImportExport ] } ] ,
371+ [ FUN_ALL_TOKEN , { functions : [ ...functions4Grant . functions ] , features : [ ] } ] ,
372+ ...groupEntries ,
373+ ...singleFunctionEntries ,
236374] )
237-
0 commit comments