@yuneta/gobj-ui 7.26.31 → 7.26.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,13 @@
22
22
  * one nothing calls by name (an FSM action, a callback) marks what
23
23
  * it hands on. The search runs to a fixpoint, and fails naming the
24
24
  * entry to add;
25
+ * - the table is CORRECT (V22): the same search started from no
26
+ * table finds every entry, at every position, with the same
27
+ * `exported` -- an entry nothing forwards to fails;
28
+ * - a parameter handed on through what no position can name (V22:
29
+ * `k.toLowerCase()`, a concatenation, `Object.entries(opts)`, a
30
+ * rest parameter walked, `arguments[i]`, an array pattern) fails,
31
+ * unless it is marked where it is handed;
25
32
  * - THE RULE, per call, in every module: each value a key position
26
33
  * of a key function can take is a string literal (or nothing, or
27
34
  * DOM), a const the reader follows to one, a key position of the
@@ -29,16 +36,20 @@
29
36
  * handed -- a comment `i18n: <NAME>_I18N_KEYS` (the module
30
37
  * publishes those keys) or `i18n: data` (no key a list can hold).
31
38
  * Anything else fails, naming the file and the line; so does a
32
- * translated text, t("..."), marked or not;
39
+ * translated text -- t("..."), tr(), an alias of t -- marked or
40
+ * not;
33
41
  * - every marker is one and stands where a key is read; the lists
34
42
  * it names are published by its module or one it imports; the
35
43
  * keys it marks that can be read are in them -- a marked `let`
36
44
  * included (or a member or an element read from one), whose
37
45
  * every literal is read, and which fails when it holds nothing
38
46
  * but literals (a const not written as one); a `data` marker on
39
- * what the reader follows to keys fails (V21.4.2);
40
- * - every published list is readable (exported, an array of
41
- * literals), lower-case ASCII, has no duplicate and no stale key;
47
+ * what the reader follows to keys fails (V21.4.2), and so does
48
+ * one that would hide a key (V22: a call mixing keys and data, an
49
+ * array of string literals);
50
+ * - every published list is readable (an `export const` array of
51
+ * literals), never mutated, lower-case ASCII, has no duplicate
52
+ * and no stale key;
42
53
  * - a list a module derives from its SOURCE (a table, a default, a
43
54
  * value it returns or assigns) covers it (COVERAGE), the source
44
55
  * read with the validator's reader -- a const followed, a `let`
@@ -56,7 +67,7 @@
56
67
  import {describe, test, expect} from "vitest";
57
68
  import {readFileSync, readdirSync, existsSync} from "node:fs";
58
69
  import {dirname, join} from "node:path";
59
- import {fileURLToPath} from "node:url";
70
+ import {fileURLToPath, pathToFileURL} from "node:url";
60
71
 
61
72
  import {I18N_KEY_FUNCTIONS} from "./i18n_key_functions.js";
62
73
  import {
@@ -72,8 +83,12 @@ import {
72
83
  marker_of,
73
84
  key_calls,
74
85
  key_places,
86
+ call_kind,
87
+ is_translation,
75
88
  call_rule_violations,
89
+ param_flows,
76
90
  published_lists,
91
+ list_mutations,
77
92
  scan_ast,
78
93
  lib_imports
79
94
  } from "../scripts/validate-locales.mjs";
@@ -84,10 +99,11 @@ const MODULES = readdirSync(SRC)
84
99
  .filter((f) => f.endsWith(".js") && !f.endsWith(".test.js"))
85
100
  .sort();
86
101
 
87
- /* A module's code parsed, knowing its name (for the messages) */
102
+ /* A module's code parsed, knowing its name (for the messages) and its
103
+ * file (an import of a sibling is followed there) */
88
104
  function parse_as(code, module)
89
105
  {
90
- let ast = parse_source(code);
106
+ let ast = parse_source(code, pathToFileURL(join(SRC, module)));
91
107
  Object.defineProperty(ast, "module", {value: module, enumerable: false});
92
108
  return ast;
93
109
  }
@@ -595,16 +611,18 @@ function with_key_dropped(lists, name, key)
595
611
 
596
612
  /*
597
613
  * The FORWARDERS: the functions of the library that hand one of their
598
- * parameters on to a place a key is read (key_places(): t() / tr(), an
599
- * `i18n`, `code` or `data-i18n*` value -- a property, setAttribute(),
600
- * `x.i18n = ...` --, a key position of a function of the table): the
601
- * parameter itself or what is read from it (`opts.title`, a
602
- * destructured `{label}`, each `items[].name`), directly or through a
603
- * `let` it is given to. What their callers give there is a key the
604
- * validator reads ONLY when the function is in I18N_KEY_FUNCTIONS: up
605
- * to 7.26.28 39 were not (the review named 17 of them), 6 entries
606
- * lacked positions, and a literal handed to one would have gone unasked
607
- * (REV-CLOUD V21.4.1).
614
+ * parameters on to a place a key is read (key_places(): a translator
615
+ * call -- t(), tr(), an alias, `.call()` --, an `i18n`, `code` or
616
+ * `data-i18n*` value -- a property, setAttribute(), `x.i18n = ...`,
617
+ * `el.dataset.i18nTitle = ...` --, a key position of a function of the
618
+ * table): the parameter itself or what is read from it (`opts.title`,
619
+ * `opts?.title`, a destructured `{label}` -- in the parameter or in the
620
+ * body --, each `items[].name`, `arguments[1]`, `rest[0]`, a copy
621
+ * `{...opts}`), directly or through a `let` it is given to. What their
622
+ * callers give there is a key the validator reads ONLY when the
623
+ * function is in I18N_KEY_FUNCTIONS: up to 7.26.28 39 were not (the
624
+ * review named 17 of them), 6 entries lacked positions, and a literal
625
+ * handed to one would have gone unasked (REV-CLOUD V21.4.1).
608
626
  *
609
627
  * - A forwarder called by NAME -- in its module, or exported -- goes
610
628
  * in the table, with that position.
@@ -615,16 +633,32 @@ function with_key_dropped(lists, name, key)
615
633
  * - What a gobj keeps in its `priv` is its own state: a caller hands
616
634
  * the gobj, never what its priv holds. A path through the priv of
617
635
  * a parameter is no position of the function.
636
+ * - A parameter handed on through what NO position can name (V22) --
637
+ * a key transformed (`k.toLowerCase()`, a concatenation, a template),
638
+ * an iteration of `Object.entries(opts)`, a rest parameter walked or
639
+ * read by a computed index, `arguments` --, unmarked, is OPAQUE: the
640
+ * callers' keys reach no reader. It is restructured (the key handed
641
+ * on as it arrives, the function then in the table) or marked.
618
642
  *
619
643
  * The search runs to a fixpoint: a function that hands a parameter to
620
- * a key position of a forwarder it found is a forwarder too.
644
+ * a key position of a forwarder it found is a forwarder too. Each round
645
+ * reads the library again with the table of the round before (a `tr`
646
+ * of the table is a key function, read at its positions, and not a
647
+ * translator); `table` is where it starts: the real table, to find what
648
+ * it lacks, or none at all, to find what the library ALONE says
649
+ * (correctness).
621
650
  *
622
651
  * -> {missing: [{module, name, exported, keys, line}],
623
- * unmarked: [{module, name, how, pos, line}]}
652
+ * unmarked: [{module, name, how, pos, line}],
653
+ * opaque: [{module, name, pos, line, text}],
654
+ * found: [{module, name, exported, keys}]}
624
655
  *
625
656
  * `missing`: what the table lacks, each function with the positions it
626
657
  * lacks and the first line that hands one. `unmarked`: a forwarder
627
- * nothing calls by name that hands a parameter on unmarked.
658
+ * nothing calls by name that hands a parameter on unmarked. `opaque`:
659
+ * a function that hands a parameter on unmarked through what no
660
+ * position can name. `found`: every forwarder and position the last
661
+ * round found, the table aside.
628
662
  */
629
663
  const PRIV_PATH_RE = /^\.priv(?:[.[]|$)/;
630
664
 
@@ -639,6 +673,29 @@ function handed_params(leaf)
639
673
  return [];
640
674
  }
641
675
 
676
+ /* The parameters a leaf hands on through what no position can name
677
+ * (param_flows()), a `let` it holds followed into what it is given */
678
+ function opaque_params(leaf)
679
+ {
680
+ if(leaf.kind !== "unknown") {
681
+ return [];
682
+ }
683
+ if(!leaf.var_root) {
684
+ return param_flows(leaf.node, leaf.scope || []);
685
+ }
686
+ let r = var_leaves(leaf.var_root);
687
+ let out = [];
688
+ for(let l of r.leaves) {
689
+ if(l.kind === "unknown" && !l.var_root) {
690
+ out.push(...param_flows(l.node, l.scope || []));
691
+ }
692
+ }
693
+ for(let o of r.opaque) {
694
+ out.push(...param_flows(o.node, o.scope || []));
695
+ }
696
+ return out;
697
+ }
698
+
642
699
  /* Is `fn` called by its name in the module (a call that resolves to it)? */
643
700
  function is_called_by_name(ast, fn)
644
701
  {
@@ -683,21 +740,70 @@ function declared_as(ast, fn)
683
740
  return how;
684
741
  }
685
742
 
743
+ /* A flow of param_flows() out of a gobj's OWN state: the parameter
744
+ * `gobj` of a gclass function (what is read from it, through a call
745
+ * too -- gobj_read_attr(gobj, "title") -- is its attrs, set by whoever
746
+ * created it), or a path through a `priv`. No caller hands a key that
747
+ * way. */
748
+ function is_gobj_state(f)
749
+ {
750
+ if(f.priv || (f.path !== null && /\.priv(?:[.[]|$)/.test(f.path))) {
751
+ return true;
752
+ }
753
+ let b = f.binding;
754
+ let p = (b.kind === "arguments")? null : b.fn.params[b.index];
755
+ if(p && p.type === "AssignmentPattern") {
756
+ p = p.left;
757
+ }
758
+ return !!p && p.type === "Identifier" && p.name === "gobj";
759
+ }
760
+
761
+ /* The parameter a flow of param_flows() comes from, and what is read
762
+ * from it when that can be said */
763
+ function flow_position(f)
764
+ {
765
+ let b = f.binding;
766
+ if(b.kind === "arguments") {
767
+ return "arguments";
768
+ }
769
+ return `${b.index}${f.path || ""}`;
770
+ }
771
+
686
772
  function find_forwarders(modules = MODULES, asts = ast_of, table = I18N_KEY_FUNCTIONS)
687
773
  {
688
- let virtual = table.map((e) => Object.assign({}, e, {keys: [...e.keys]}));
774
+ let start = table.map((e) => Object.assign({}, e, {keys: [...e.keys]}));
775
+ let named = new Map(); // a function node -> {by_name, how}
776
+ let round_table = start;
777
+ let found = new Map(); // "<module> <name>" -> {module, name, exported, keys}
689
778
  let first_line = new Map(); // "<module> <name> <pos>" -> line
690
779
  let unmarked = new Map(); // "<module>:<line> <pos>" -> {...}
691
- let named = new Map(); // a function node -> is it called by name?
780
+ let opaque = new Map(); // "<module>:<line> <pos>" -> {...}
781
+ let signature = (entries) => entries.map((e) => `${e.module} ${e.name} [${[...e.keys].sort()}]`).sort().join("\n");
782
+ let seen = new Set();
692
783
  for(let round = 0; round < 32; round++) {
693
- let grew = false;
694
- let round_table = virtual.slice(); // a new array: the index is cached per table
784
+ found = new Map();
785
+ first_line = new Map();
786
+ unmarked = new Map();
787
+ opaque = new Map();
695
788
  for(let module of modules) {
696
789
  let ast = asts(module);
697
790
  let markers = markers_of(ast);
698
791
  let exported = exported_names(ast);
792
+ let by_name_of = (b) => {
793
+ if(!named.has(b.fn)) {
794
+ let how = declared_as(ast, b.fn);
795
+ let by_name = !!b.name && (how === "declaration" || how === "variable")
796
+ && (exported.has(b.name) || is_called_by_name(ast, b.fn));
797
+ named.set(b.fn, {by_name, how});
798
+ }
799
+ return named.get(b.fn);
800
+ };
699
801
  for(let place of key_places(ast, {lib_module: module, table: round_table})) {
802
+ let place_line = line_of(ast, place.node.start);
700
803
  for(let leaf of place.leaves) {
804
+ if(marker_of(leaf, markers)) {
805
+ continue;
806
+ }
701
807
  for(let p of handed_params(leaf)) {
702
808
  if(PRIV_PATH_RE.test(p.path)) {
703
809
  continue;
@@ -705,44 +811,64 @@ function find_forwarders(modules = MODULES, asts = ast_of, table = I18N_KEY_FUNC
705
811
  let b = p.binding;
706
812
  let pos = `${b.index}${p.path}`;
707
813
  let line = line_of(ast, leaf.node.start); // the place it is handed to
708
- if(!named.has(b.fn)) {
709
- let how = declared_as(ast, b.fn);
710
- let by_name = !!b.name && (how === "declaration" || how === "variable")
711
- && (exported.has(b.name) || is_called_by_name(ast, b.fn));
712
- named.set(b.fn, {by_name, how});
713
- }
714
- let {by_name, how} = named.get(b.fn);
814
+ let {by_name, how} = by_name_of(b);
715
815
  if(!by_name) {
716
- if(!marker_of(leaf, markers)) {
717
- unmarked.set(`${module}:${line} ${pos}`,
718
- {module, name: b.name, how, pos, line});
719
- }
816
+ unmarked.set(`${module}:${line} ${pos}`, {module, name: b.name, how, pos, line});
720
817
  continue;
721
818
  }
722
819
  let id = `${module} ${b.name} ${pos}`;
723
820
  if(!first_line.has(id) || line < first_line.get(id)) {
724
821
  first_line.set(id, line);
725
822
  }
726
- let entry = virtual.find((e) => e.module === module && e.name === b.name);
727
- if(!entry) {
728
- entry = {module, name: b.name, exported: exported.has(b.name), keys: []};
729
- virtual.push(entry);
823
+ let key = `${module} ${b.name}`;
824
+ if(!found.has(key)) {
825
+ found.set(key, {module, name: b.name, exported: exported.has(b.name), keys: []});
826
+ }
827
+ if(!found.get(key).keys.includes(pos)) {
828
+ found.get(key).keys.push(pos);
730
829
  }
731
- if(!entry.keys.includes(pos)) {
732
- entry.keys.push(pos);
733
- grew = true;
830
+ }
831
+ if(is_translation(leaf)) {
832
+ continue; // the rule test says what is wrong with it
833
+ }
834
+ for(let f of opaque_params(leaf)) {
835
+ if(is_gobj_state(f)) {
836
+ continue;
734
837
  }
838
+ let pos = flow_position(f);
839
+ opaque.set(`${module}:${place_line} ${pos}`, {
840
+ module, name: f.binding.name, pos, line: place_line,
841
+ text: ast.code.slice(leaf.node.start, leaf.node.end).replace(/\s+/g, " ").slice(0, 80)
842
+ });
843
+ }
844
+ }
845
+ }
846
+ }
847
+ /* The next round's table: where it started, and what this one found */
848
+ let next = start.map((e) => Object.assign({}, e, {keys: [...e.keys]}));
849
+ for(let f of found.values()) {
850
+ let e = next.find((x) => x.module === f.module && x.name === f.name);
851
+ if(!e) {
852
+ next.push({module: f.module, name: f.name, exported: f.exported, keys: [...f.keys]});
853
+ } else {
854
+ for(let k of f.keys) {
855
+ if(!e.keys.includes(k)) {
856
+ e.keys.push(k);
735
857
  }
736
858
  }
737
859
  }
738
860
  }
739
- if(!grew) {
861
+ let sig = signature(next);
862
+ if(sig === signature(round_table) || seen.has(sig)) {
863
+ round_table = next;
740
864
  break;
741
865
  }
866
+ seen.add(sig);
867
+ round_table = next;
742
868
  }
743
869
 
744
870
  let missing = [];
745
- for(let entry of virtual) {
871
+ for(let entry of round_table) {
746
872
  let real = table.find((e) => e.module === entry.module && e.name === entry.name);
747
873
  let keys = entry.keys.filter((k) => !real || !real.keys.includes(k));
748
874
  if(keys.length === 0) {
@@ -752,7 +878,7 @@ function find_forwarders(modules = MODULES, asts = ast_of, table = I18N_KEY_FUNC
752
878
  missing.push({module: entry.module, name: entry.name, exported: entry.exported, keys,
753
879
  line: Math.min(...lines), in_table: !!real});
754
880
  }
755
- return {missing, unmarked: [...unmarked.values()]};
881
+ return {missing, unmarked: [...unmarked.values()], opaque: [...opaque.values()], found: [...found.values()]};
756
882
  }
757
883
 
758
884
  function forwarders_report(found)
@@ -769,6 +895,39 @@ function forwarders_report(found)
769
895
  `hands its parameter [${u.pos}] to a place a key is read, unmarked: no caller can be ` +
770
896
  `read, so mark what it is where it hands it`);
771
897
  }
898
+ for(let o of found.opaque || []) {
899
+ out.push(`${o.module}:${o.line} ${o.name || "a function"}() hands its parameter [${o.pos}] to a place ` +
900
+ `a key is read through \`${o.text}\`, which no position can name: hand the key on as it ` +
901
+ `arrives (the function then goes in the table), or mark it where it hands it`);
902
+ }
903
+ return out;
904
+ }
905
+
906
+ /*
907
+ * The table is CORRECT: every entry, at every position, is a hand-off
908
+ * the library itself shows -- the search started from NO table finds
909
+ * it, with the same `exported`. An entry nothing forwards to would make
910
+ * the validator read keys where no key goes, and a wrong `exported`
911
+ * hides the calls of another module. (Up to 7.26.31 the test proved
912
+ * only that the table was complete: an extra entry passed it.)
913
+ */
914
+ function table_errors(found, table = I18N_KEY_FUNCTIONS)
915
+ {
916
+ let out = [];
917
+ for(let e of table) {
918
+ let f = found.find((x) => x.module === e.module && x.name === e.name);
919
+ for(let k of e.keys) {
920
+ if(!f || !f.keys.includes(k)) {
921
+ out.push(`${e.module} ${e.name} [${k}]: no hand-off the search finds -- the function does not ` +
922
+ `hand that position to a place a key is read: drop it from I18N_KEY_FUNCTIONS, or ` +
923
+ `write the hand-off in a form the reader follows`);
924
+ }
925
+ }
926
+ if(f && f.exported !== e.exported) {
927
+ out.push(`${e.module} ${e.name}: \`exported: ${e.exported}\`, and the module ` +
928
+ `${f.exported? "exports" : "does not export"} it`);
929
+ }
930
+ }
772
931
  return out;
773
932
  }
774
933
 
@@ -818,6 +977,10 @@ describe("REV-CLOUD V21.4.1: every forwarder is in the table", () => {
818
977
  expect(forwarders_report(find_forwarders())).toEqual([]);
819
978
  });
820
979
 
980
+ test("REV-CLOUD V22: the table is CORRECT -- every entry is a hand-off the library shows", () => {
981
+ expect(table_errors(find_forwarders(MODULES, ast_of, []).found)).toEqual([]);
982
+ });
983
+
821
984
  test("the search finds what it should, and nothing else", () => {
822
985
  let code = [
823
986
  `import {yui_shell_show_error} from "./shell_modals.js";`,
@@ -975,8 +1138,8 @@ describe("the reader the rule and the validator share", () => {
975
1138
  ].join("\n")), {lib_module: module});
976
1139
  expect(r.marker_errors.map((e) => e.line)).toEqual([1, 2]);
977
1140
  expect(r.marker_errors[0].why).toMatch(/is no i18n marker/);
978
- expect(r.marker_errors[1].why).toMatch(/stands before no place a key is read/);
979
- expect(r.marker_refs).toEqual([{lists: ["C_YUI_FOO_I18N_KEYS"], keys: ["listed"], line: 3}]);
1141
+ expect(r.marker_errors[1].why).toMatch(/^the i18n marker stands before `1;`, the value a variable is DECLARED with/);
1142
+ expect(r.marker_refs).toEqual([{lists: ["C_YUI_FOO_I18N_KEYS"], keys: ["listed"], line: 3, shapes: []}]);
980
1143
  });
981
1144
 
982
1145
  test("a let or var a marker stands before is HELD to it: everything it is given is read", () => {
@@ -1376,7 +1539,6 @@ describe("the published *_I18N_KEYS lists", () => {
1376
1539
  let report = unpinned_keys(lists, COVERAGE.filter((e) => !added.includes(e.list)));
1377
1540
  let reported = (name, key) => report.some((line) => line.includes(`${name}: ${JSON.stringify(key)} `));
1378
1541
  for(let [name, key] of [
1379
- ["C_YUI_FORM_I18N_KEYS", "this field is required"],
1380
1542
  ["FORM_JSON_FIELD_I18N_KEYS", "invalid json"],
1381
1543
  ["SHELL_ROUTE_MAP_I18N_KEYS", "toolbar"],
1382
1544
  ["SHELL_ROUTE_MAP_I18N_KEYS", "other routes"],
@@ -1397,6 +1559,13 @@ describe("the published *_I18N_KEYS lists", () => {
1397
1559
  let route_map_refs = scan_of("shell_route_map.js").marker_refs
1398
1560
  .filter((ref) => ref.lists.includes("SHELL_ROUTE_MAP_I18N_KEYS"));
1399
1561
  expect(route_map_refs.flatMap((ref) => ref.keys)).toContain("app");
1562
+ /* ...and "this field is required" since V22: field_validation_key()
1563
+ * returns it OR the json field's own error, and the list marker on
1564
+ * the `let key` that holds the call holds that key to its lists */
1565
+ expect(reported("C_YUI_FORM_I18N_KEYS", "this field is required")).toBe(false);
1566
+ let form_refs = scan_of("c_yui_form.js").marker_refs
1567
+ .filter((ref) => ref.lists.includes("C_YUI_FORM_I18N_KEYS"));
1568
+ expect(form_refs.flatMap((ref) => ref.keys)).toContain("this field is required");
1400
1569
 
1401
1570
  for(let [name, key] of [
1402
1571
  ["C_YUI_FORM_I18N_KEYS", "this field is required"],
@@ -1446,7 +1615,7 @@ describe("the published *_I18N_KEYS lists", () => {
1446
1615
  `const K = "not in it";`,
1447
1616
  `t(/* i18n: X_I18N_KEYS */ K);`
1448
1617
  ].join("\n")), {lib_module: "x.js"});
1449
- expect(r.marker_refs).toEqual([{lists: ["X_I18N_KEYS"], keys: ["not in it"], line: 3}]);
1618
+ expect(r.marker_refs).toEqual([{lists: ["X_I18N_KEYS"], keys: ["not in it"], line: 3, shapes: []}]);
1450
1619
  });
1451
1620
  });
1452
1621
 
@@ -1523,3 +1692,246 @@ describe("REV-CLOUD V20 (js3_v lists #2): a COVERAGE source is read the way the
1523
1692
  /^c_yui_form\.js:\d+: C_YUI_FORM_I18N_KEYS is derived from this source, and the reader cannot follow \$input\.yui_json_error:/)]);
1524
1693
  });
1525
1694
  });
1695
+
1696
+ describe("REV-CLOUD V22.1: a recursive helper is read once, and never hangs the reader", () => {
1697
+ let module = "x.js";
1698
+ let head = [`import {yui_shell_show_info} from "./shell_modals.js";`];
1699
+ let rule = (lines) => rule_report(parse_as([...head, ...lines].join("\n"), module), module);
1700
+ let scan = (lines) => scan_ast(parse_as([...head, ...lines].join("\n"), module), {lib_module: module});
1701
+
1702
+ /* The review's case, verbatim: 7.26.28 read it in 12 ms, 7.26.29 in
1703
+ * more than two minutes (x3 per level of MAX_DEPTH), and with three
1704
+ * recursive ifs it never ended */
1705
+ let label_of = (branches) => [
1706
+ `function label_of(n) {`,
1707
+ ` if(n.alias) { return label_of(n.alias); }`,
1708
+ ` if(n.parent) { return label_of(n.parent); }`,
1709
+ ...(branches > 2? [` if(n.other) { return label_of(n.other); }`] : []),
1710
+ ` return n.name;`,
1711
+ `}`
1712
+ ];
1713
+
1714
+ test("the review's case (two and three recursive branches): fast, and `n.name` is data", () => {
1715
+ for(let branches of [2, 3]) {
1716
+ let t0 = Date.now();
1717
+ let lines = [...label_of(branches),
1718
+ `export function f($el, node, shell) {`,
1719
+ ` $el.textContent = t(label_of(node));`,
1720
+ ` yui_shell_show_info(shell, label_of(node));`,
1721
+ `}`];
1722
+ let at = 1 + lines.indexOf(` yui_shell_show_info(shell, label_of(node));`) + 1;
1723
+ expect(rule(lines)).toEqual([`${module}:${at} yui_shell_show_info() [1]: label_of(node)`]);
1724
+ let marked = lines.map((l) => l.replace("yui_shell_show_info(shell, label_of(node))",
1725
+ "yui_shell_show_info(shell, /* i18n: data */ label_of(node))"));
1726
+ expect(rule(marked)).toEqual([]);
1727
+ expect(scan(marked).marker_errors).toEqual([]);
1728
+ expect(Date.now() - t0).toBeLessThan(2000);
1729
+ }
1730
+ });
1731
+
1732
+ test("a recursion is not followed, whatever it returns; a helper called on many paths is read once", () => {
1733
+ /* Returns only literals, through itself: the reader does not follow
1734
+ * a recursion, so the call is a value it cannot follow */
1735
+ expect(rule([`function up(n) { if(n.parent) { return up(n.parent); } return "root"; }`,
1736
+ `yui_shell_show_info(shell, up(node));`]))
1737
+ .toEqual([`${module}:3 yui_shell_show_info() [1]: up(node)`]);
1738
+ /* Mutual recursion, the same */
1739
+ expect(rule([`function a(n) { return n? b(n.x) : "from a"; }`,
1740
+ `function b(n) { return n? a(n.y) : "from b"; }`,
1741
+ `yui_shell_show_info(shell, a(node));`]).length).toBe(1);
1742
+
1743
+ /* A fan: 16 helpers, each calling the next in three branches. Read
1744
+ * at every call, as 7.26.29 did, it is 3^16 reads */
1745
+ let fan = [];
1746
+ for(let i = 0; i < 16; i++) {
1747
+ fan.push(`function h${i}(c) { if(c === 1) { return h${i + 1}(c); } ` +
1748
+ `if(c === 2) { return h${i + 1}(c); } return h${i + 1}(c); }`);
1749
+ }
1750
+ fan.push(`function h16(c) { return c? "deep key" : "other deep key"; }`);
1751
+ let t0 = Date.now();
1752
+ let r = scan([...fan, `t(h0(1));`]);
1753
+ expect(Date.now() - t0).toBeLessThan(2000);
1754
+ expect(r.keys.has("deep key") && r.keys.has("other deep key")).toBe(true);
1755
+ });
1756
+ });
1757
+
1758
+ describe("REV-CLOUD V22.2: what the search for forwarders sees, and what fails it", () => {
1759
+ let module = "x.js";
1760
+ let found_in = (lines) => {
1761
+ let ast = parse_as(lines.join("\n"), module);
1762
+ return find_forwarders([module], () => ast, []);
1763
+ };
1764
+ let keys_of = (found) => Object.fromEntries(found.found.map((f) => [f.name, f.keys.slice().sort()]));
1765
+
1766
+ test("the forms it follows: each one is a position of the function", () => {
1767
+ let found = found_in([
1768
+ `import i18next from "i18next";`,
1769
+ `import {t as tt} from "i18next";`,
1770
+ `const ATTR = "data-i18n-title";`,
1771
+ `const PROP = "data-i18n";`,
1772
+ `export function f_opt(gobj, opts) { return t(opts?.title); }`,
1773
+ `export function f_args(gobj) { return t(arguments[1]); }`,
1774
+ `export function f_alias(gobj, k) { const translate = t; return translate(k); }`,
1775
+ `export function f_import(gobj, k) { return tt(k); }`,
1776
+ `export function f_optcall(gobj, k) { return t?.(k); }`,
1777
+ `export function f_call(gobj, k) { return t.call(null, k); }`,
1778
+ `export function f_bind(gobj, k) { const tb = i18next.t.bind(i18next); return tb(k); }`,
1779
+ `export function f_destr(gobj, opts) { const {title} = opts; let {label} = opts; return [t(title), t(label)]; }`,
1780
+ `export function f_copy(gobj, opts) { const o = {...opts}; const p = Object.assign({}, opts); return [t(o.title), t(p.label)]; }`,
1781
+ `export function f_rest(gobj, ...rest) { return t(rest[0]); }`,
1782
+ `export function f_list(gobj, k) { return t([k, "a fallback key"]); }`,
1783
+ `export function f_dataset(gobj, el, k) { el.dataset.i18nTitle = k; }`,
1784
+ `export function f_attr(gobj, el, k) { el.setAttribute(ATTR, k); }`,
1785
+ `export function f_prop(gobj, k) { return {[PROP]: k}; }`,
1786
+ ]);
1787
+ expect(found.opaque).toEqual([]);
1788
+ expect(keys_of(found)).toEqual({
1789
+ f_opt: ["1.title"], f_args: ["1"], f_alias: ["1"], f_import: ["1"], f_optcall: ["1"],
1790
+ f_call: ["1"], f_bind: ["1"], f_destr: ["1.label", "1.title"], f_copy: ["1.label", "1.title"],
1791
+ f_rest: ["1"], f_list: ["1"], f_dataset: ["2"], f_attr: ["2"], f_prop: ["1"]
1792
+ });
1793
+ let r = scan_ast(parse_as(`export function f(gobj, k) { return t([k, "a fallback key"]); }`, module),
1794
+ {lib_module: module});
1795
+ expect(r.keys.has("a fallback key")).toBe(true); // an array given to t() is a list of KEYS
1796
+ });
1797
+
1798
+ test("what no position can name FAILS, named, with the file and the line -- unless it is marked", () => {
1799
+ let lines = [
1800
+ `export function o_lower(gobj, k) { return t(k.toLowerCase()); }`,
1801
+ `export function o_entries(gobj, opts) { Object.entries(opts).forEach(([k, v]) => t(v)); }`,
1802
+ `export function o_rest(gobj, ...rest) { rest.forEach((x) => t(x)); }`,
1803
+ `export function o_concat(gobj, k) { return {i18n: "prefix " + k}; }`,
1804
+ `export function o_index(gobj, i) { return t(arguments[i]); }`,
1805
+ `export function o_array(gobj, [a, b]) { return t(a); }`,
1806
+ `export function o_let(gobj, k) { let x = "a"; x += k; return t(x); }`,
1807
+ `export function o_own(gobj) { return t(gobj_read_attr(gobj, "title").trim()); }`, // the gobj's own state
1808
+ `export function o_priv(gobj, ctx) { return t(ctx.gobj.priv.label.trim()); }`, // a priv
1809
+ ];
1810
+ let found = found_in(lines);
1811
+ expect(found.opaque.map((o) => `${o.name} ${o.line} ${o.pos}`)).toEqual([
1812
+ "o_lower 1 1", "o_entries 2 1", "o_rest 3 1", "o_concat 4 1", "o_index 5 arguments",
1813
+ "o_array 6 1", "o_let 7 1"
1814
+ ]);
1815
+ expect(forwarders_report(found)[0]).toBe("x.js:1 o_lower() hands its parameter [1] to a place a key " +
1816
+ "is read through `k.toLowerCase()`, which no position can name: hand the key on as it arrives (the " +
1817
+ "function then goes in the table), or mark it where it hands it");
1818
+ /* ...and marked where it hands it, it passes */
1819
+ let marked = found_in([
1820
+ `export function o_lower(gobj, k) { return t(/* i18n: data */ k.toLowerCase()); }`,
1821
+ `export function o_entries(gobj, opts) { Object.entries(opts).forEach(([k, v]) => t(/* i18n: data */ v)); }`
1822
+ ]);
1823
+ expect(marked.opaque).toEqual([]);
1824
+ });
1825
+
1826
+ test("the table is checked for CORRECTNESS, not only for completeness: an extra entry fails", () => {
1827
+ let found = find_forwarders(MODULES, ast_of, []).found;
1828
+ let extra = [...I18N_KEY_FUNCTIONS.filter((e) => e.name !== "yui_shell_show_info"),
1829
+ {module: "shell_modals.js", name: "yui_shell_show_info", exported: true, keys: ["1", "2.extra"]},
1830
+ {module: "shell_modals.js", name: "yui_shell_show_warning", exported: false, keys: ["1"]},
1831
+ {module: "c_yui_nav.js", name: "no_such_forwarder", exported: false, keys: ["0"]}];
1832
+ let errors = table_errors(found, extra);
1833
+ expect(errors).toEqual(expect.arrayContaining([
1834
+ expect.stringMatching(/^shell_modals\.js yui_shell_show_info \[2\.extra\]: no hand-off the search finds/),
1835
+ "shell_modals.js yui_shell_show_warning: `exported: false`, and the module exports it",
1836
+ expect.stringMatching(/^c_yui_nav\.js no_such_forwarder \[0\]: no hand-off the search finds/)
1837
+ ]));
1838
+ /* ...as the real table was, before V22: an action label that went
1839
+ * through a DOM property, a flag that hid an export */
1840
+ expect(table_errors(found)).toEqual([]);
1841
+ });
1842
+ });
1843
+
1844
+ describe("REV-CLOUD V22.3-4: the data marker hides no key; one notion of a translator", () => {
1845
+ let module = "x.js";
1846
+ let head = [`import {yui_shell_show_info} from "./shell_modals.js";`,
1847
+ `import {t as tt} from "i18next";`];
1848
+ let scan = (lines) => scan_ast(parse_as([...head, ...lines].join("\n"), module), {lib_module: module});
1849
+ let rule = (lines) => rule_report(parse_as([...head, ...lines].join("\n"), module), module);
1850
+ let whys = (r) => r.marker_errors.map((e) => `${e.line}: ${e.why}`);
1851
+
1852
+ test("a data marker on a call that MIXES a key with data fails: split the branches", () => {
1853
+ let r = scan([`function label(kw) { return kw.ok? "a mixed key" : kw.comment; }`,
1854
+ `yui_shell_show_info(shell, /* i18n: data */ label(kw));`,
1855
+ `yui_shell_show_info(shell, /* i18n: data */ kw.comment || "a split key");`]);
1856
+ expect(whys(r)).toEqual([expect.stringMatching(
1857
+ /^4: marked as data, but `label\(kw\)` can also return the key "a mixed key": .* Split the branches/)]);
1858
+ expect(r.keys.has("a split key")).toBe(true);
1859
+ /* Under a list marker, that key is asked for and must be in the list */
1860
+ let l = scan([`export const X_I18N_KEYS = ["in it"];`,
1861
+ `function label(kw) { return kw.ok? "a mixed key" : kw.comment; }`,
1862
+ `yui_shell_show_info(shell, /* i18n: X_I18N_KEYS */ label(kw));`]);
1863
+ expect(l.marker_refs).toEqual([{lists: ["X_I18N_KEYS"], keys: ["a mixed key"], line: 5, shapes: []}]);
1864
+ });
1865
+
1866
+ test("a data marker on an array of strings fails: it is no DOM", () => {
1867
+ let r = scan([`yui_shell_show_info(shell, /* i18n: data */ ["a key", "another key"]);`,
1868
+ `yui_shell_show_info(shell, /* i18n: data */ ["div", {}, "a spec is DOM"]);`]);
1869
+ expect(whys(r)).toEqual([expect.stringMatching(
1870
+ /^3: marked as data, but what it marks is an array of string literals \("a key", "another key"\), which is no DOM/)]);
1871
+ expect(rule([`yui_shell_show_info(shell, ["a key", "another key"]);`]))
1872
+ .toEqual([`${module}:3 yui_shell_show_info() [1]: ["a key", "another key"]`]);
1873
+ });
1874
+
1875
+ test("a marker attaches through a parenthesis, and one that attaches nowhere says what it stands before", () => {
1876
+ let r = scan([`yui_shell_show_info(shell, /* i18n: data */ (kw.comment || "a fallback in parens"));`,
1877
+ `function f(kw) { return t(/* i18n: data */ (kw.a || kw.b)); }`,
1878
+ `let x = /* i18n: data */ kw.comment;`,
1879
+ `x = /* i18n: data */ kw.other;`,
1880
+ `function g() { return /* i18n: data */ kw.third; }`,
1881
+ `t("a" + /* i18n: data */ kw.fourth);`]);
1882
+ expect(r.keys.has("a fallback in parens")).toBe(true);
1883
+ expect(whys(r)).toEqual([
1884
+ expect.stringMatching(/^5: the i18n marker stands before `kw\.comment;`, the value a variable is DECLARED with/),
1885
+ expect.stringMatching(/^6: the i18n marker stands before `kw\.other;`, a value ASSIGNED/),
1886
+ expect.stringMatching(/^7: the i18n marker stands before `kw\.third; }`, a value RETURNED/),
1887
+ expect.stringMatching(/^8: the i18n marker stands before `kw\.fourth\);`, inside a concatenation/)
1888
+ ]);
1889
+ });
1890
+
1891
+ test("a translated text at a key position fails, MARKED too, whatever the translator is called", () => {
1892
+ let lines = [
1893
+ `const translate = t;`,
1894
+ `function tr(k) { return t(k); }`,
1895
+ `yui_shell_show_info(shell, /* i18n: data */ tr("saved"));`,
1896
+ `yui_shell_show_info(shell, /* i18n: data */ translate("saved"));`,
1897
+ `yui_shell_show_info(shell, /* i18n: data */ tt("saved"));`,
1898
+ `yui_shell_show_info(shell, /* i18n: data */ t?.("saved"));`,
1899
+ `yui_shell_show_info(shell, /* i18n: data */ t.call(null, "saved"));`,
1900
+ `yui_shell_show_info(shell, tr("saved"));`
1901
+ ];
1902
+ expect(rule(lines)).toEqual([
1903
+ [5, `tr("saved")`], [6, `translate("saved")`], [7, `tt("saved")`], [8, `t?.("saved")`],
1904
+ [9, `t.call(null, "saved")`], [10, `tr("saved")`]
1905
+ ].map(([n, text]) => `${module}:${n} yui_shell_show_info() [1]: ${text} -- a TRANSLATED text where a key goes: hand the key`));
1906
+ });
1907
+
1908
+ test("a key function is read however it is called: an alias, a map, .call(), .apply()", () => {
1909
+ let r = scan([`const show = yui_shell_show_info;`,
1910
+ `const API = {info: yui_shell_show_info};`,
1911
+ `show(shell, "an alias key");`,
1912
+ `API.info(shell, "a map key");`,
1913
+ `yui_shell_show_info.call(null, shell, "a call key");`,
1914
+ `yui_shell_show_info.apply(null, [shell, "an apply key"]);`]);
1915
+ for(let k of ["an alias key", "a map key", "a call key", "an apply key"]) {
1916
+ expect(r.keys.has(k), k).toBe(true);
1917
+ }
1918
+ expect(rule([`const show = yui_shell_show_info;`, `let k = "a let key";`, `show(shell, k);`]))
1919
+ .toEqual([`${module}:5 yui_shell_show_info() [1]: k`]);
1920
+ });
1921
+
1922
+ test("REV-CLOUD V22.5: no published list of the library is a let, and none is mutated", () => {
1923
+ let wrong = [];
1924
+ for(let module of MODULES) {
1925
+ let ast = ast_of(module);
1926
+ for(let e of list_mutations(ast, published_lists(ast).lists)) {
1927
+ wrong.push(`${module}:${e.line}: ${e.why}`);
1928
+ }
1929
+ }
1930
+ expect(wrong).toEqual([]);
1931
+ let r = published_lists(parse_source(`export let A_I18N_KEYS = ["a"];`));
1932
+ expect(r.errors).toEqual(["A_I18N_KEYS: declared with let, which can be given other keys no reader sees: " +
1933
+ "a published list is an `export const` array of literals"]);
1934
+ expect(list_mutations(parse_source(`export const B_I18N_KEYS = ["b"];\nB_I18N_KEYS.push("c");`),
1935
+ [{name: "B_I18N_KEYS", keys: ["b"]}]).map((e) => e.line)).toEqual([2]);
1936
+ });
1937
+ });