All files / lib/compiler/atlas resolve.js

94.75% Statements 975/1029
91.8% Branches 381/415
100% Functions 17/17
94.75% Lines 975/1029

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 26697x 26697x 26697x 323x 323x 323x 323x 323x 323x 55541x 55541x 55541x 323x 323x 323x 323x 323x 323x 323x 12681x 12681x 12681x 12681x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 38278x 38278x 38278x 38278x 2x 2x 2x 2x 38276x 38278x         38276x 38278x 525x 525x 525x 3130x 3130x 3130x 525x 525x 37751x 38278x 4x 4x 36x       36x 6x 6x 6x 52x 52x 52x 52x 6x 6x 6x 6x 30x 30x 4x 4x 37747x 37747x 38278x 323x 323x 323x 323x 323x 323x 323x 750x 750x 750x 750x 750x 750x 750x 8729x 8729x 155x 155x 155x 8574x 8729x 7687x 887x 887x 887x 7824x 7824x   750x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 184x 184x 184x 184x 184x 184x 184x 184x 184x 184x 184x 1193x 1193x       1193x 1193x 1192x 1191x 101x 101x 101x 1193x 184x 184x 184x 285x 285x 279x 285x 1x 1x 1x 279x 279x 279x 285x 978x 978x 977x 976x 1x 1x 1x 978x 279x 285x 279x 279x 285x 9x 9x 3x 3x 3x 3x 1x 1x 3x 2x 2x 3x 3x 9x 276x 285x           276x 276x 276x 276x 276x 285x 184x 184x 184x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 1287x 1287x 1287x 1287x 1287x 14571x 14571x 188x 3x 3x 188x 188x 188x 14383x 14383x 14383x 14571x 637x 637x 637x 637x 73x 73x 73x 637x 637x 637x 637x 13746x 14571x 10002x 10002x 10002x 3744x 3744x 14571x 3744x 14571x 14571x 14571x 14571x 1170x 1170x 1170x 2574x 14553x 51x 51x 51x 53x 1x 1x 1x 1x 53x 52x 52x 52x 52x 52x     53x 53x 53x 1912x 1912x 1x 1x 1x 1911x 1912x 1831x 81x 80x 1831x 1700x 2x 2x 2x 1858x 1858x 53x 53x 51x 51x 53x 51x 51x 51x 2523x 14553x 2x 2x 1x 1x 1x 1x 1x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2521x 14553x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 2520x 2520x 2520x 14571x 2x 2x 2520x 2520x 2520x 1287x 1287x 1287x 323x 323x 323x 323x 323x 323x 323x 323x 2444x 2444x 2444x 2444x 2444x 2444x 2444x 2444x 2444x 2444x 2444x 3624x 3624x 3624x 3x 3x 3x                                 3x 3x 3x 3x 3x 3x 3x 3621x 3624x 1159x 1159x 1159x 1159x 1159x 1159x 1159x 1159x 2462x 3624x 18x 18x 18x 18x 18x 1x 18x 17x 17x 15x 15x 15x 17x 18x 18x 18x 2444x 2444x 2444x 2444x 2444x 2444x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 18x 18x 18x 18x 3x 1x 18x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 17x 17x 17x 323x 323x 323x 323x 323x 323x 323x 2209x 2209x 2209x 1607x 2209x 493x 493x 1114x 2209x 44x 44x 1070x 2128x 16028x 3x 3x 16028x 1067x 2209x 300x 300x 767x 767x 2209x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 1295x 1295x 1295x 1295x 1295x 1295x 1295x 1295x 1295x 1295x     1295x 1295x 1295x 1295x 1295x 1295x 1295x 1295x 1295x 11873x 11873x 187x 3x 3x 3x 3x 3x 187x 187x 187x 11686x 11686x 11686x 11873x 91x 91x 91x 91x 1x 1x     1x 1x 1x 91x 91x 11685x 11873x 1x 1x 1x 1x 11684x 11873x 9187x 9187x 9187x 2497x 2497x 2497x 2497x 2497x 2497x 2497x 11873x 2497x 11855x 48x 48x 47x 47x 47x 47x 47x 46x 46x 46x 46x 47x 2x 2x 2x 11873x 2449x 2449x 2449x 11873x 11873x 5x 5x 5x 5x 5x 2444x 2444x 2444x 2444x 11873x 209x 209x 209x 2235x 11855x 15x 15x 15x 15x 15x 2235x 2235x 2235x 11873x 11873x 26x 26x 26x 2209x 2209x 2209x 11873x 1862x 2209x 2209x 11689x 1369x 1369x 1369x 1x 1x 1369x 2209x 2209x 2209x 2209x 2209x 11873x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 22x 2209x 2209x 2209x 2209x 2209x 2209x 2209x 2209x 2209x 2209x 11873x 11873x 11873x 11873x 11873x 1x 2x 2x 2x 2x 2x 2x 2x 1x 1x 2209x 11855x 44x 44x 44x 44x 44x 44x 44x 44x 2209x 2209x 2209x 1295x 1295x 1295x 323x 323x 323x 323x 323x 323x 323x 318x 318x 471x 284x 471x 318x 318x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 2318x 72x 72x 72x 72x 2318x 2318x 2318x 1760x 1760x 1760x 1760x 2318x 2318x 2318x 2318x 2318x 2318x 2318x 2318x 71x     71x 71x 2247x 2318x 1x 1x 2246x 2246x 2246x 2246x 2318x 2318x 103x 103x 2143x 2143x 2143x 2143x 2318x 467x 467x 467x 467x 467x 467x 3x 3x 464x 464x 1676x 1676x 1676x 2318x 146x 146x 146x     146x 143x 143x 3x 3x 1530x 2318x 404x 404x     404x 404x 398x 398x     404x     404x     404x     6x 6x 1126x 2318x 2318x 255x 255x 33x     2x 2x 871x 2316x 26x 26x 26x 871x 2318x 33x 33x 2318x 199x 199x 2318x 2x 2x 2318x 558x 558x 79x 2318x 22x 22x 57x 57x 2318x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 323x 679x 679x 679x 679x 679x 679x 679x 679x 79x 79x 600x 679x 401x 401x 679x 90x 90x 679x 81x 81x 28x 679x 323x 323x  
/**
 * @file resolve.js
 * @description Turns Avenx expression source text into resolved semantic
 * references.
 *
 * This is the part of Atlas that cannot be had for free. The compiler keeps
 * template expressions, computed expressions and action bodies as **source
 * text** all the way to evaluation, which is exactly what makes Atlas possible
 * — and it means Atlas has to read that text itself.
 *
 * Two passes, deliberately:
 *
 * 1. {@link collectLocals} finds every name the body binds itself — `const`,
 *    `let`, `var`, function and arrow parameters, `catch` bindings,
 *    destructuring patterns. It runs first so that a reference appearing
 *    ahead of the declaration that shadows it is still recognised as local.
 * 2. {@link scanReferences} walks the text and records every member chain,
 *    what was done to it (read, write, invoke) and where it sat in the source.
 *
 * {@link resolveReference} then maps a chain onto a declaration in scope.
 *
 * ## What this is not
 *
 * It is not a JavaScript parser, and an action body is arbitrary JavaScript.
 * The scanner is string- and comment-aware and understands member access,
 * optional chaining, computed members, calls, assignment and destructuring —
 * which covers the shapes Avenx components actually contain. Everything it
 * cannot follow is reported as an {@link UnresolvedReason}, never dropped and
 * never guessed. The house rule for this file is that a missing edge must be
 * visible somewhere; an uncertain answer beats a confidently wrong one.
 * @module lib/compiler/atlas/resolve
 */
 
import { Confidence, UnresolvedReason } from './AppModel.js';
 
/**
 * Identifiers that resolve to something outside the application's own model.
 *
 * The first group mirrors the sandbox's allowlist in
 * `lib/core/security/sandbox.js` — an expression may legitimately name these
 * and they are not application state. The second group is the scope the
 * runtime injects around a template expression or an action body.
 * @type {Set<string>}
 */
export const AMBIENT_ROOTS = new Set([
  // Sandbox-allowed globals.
  'Math', 'JSON', 'Array', 'Object', 'String', 'Number', 'Boolean', 'Date', 'Error',
  'Map', 'Set', 'Promise', 'console', 'parseInt', 'parseFloat', 'isNaN', 'isFinite',
  'decodeURI', 'decodeURIComponent', 'encodeURI', 'encodeURIComponent',
  'undefined', 'NaN', 'Infinity',
  // Language keywords the scanner may still hand back as identifiers.
  'true', 'false', 'null', 'typeof', 'instanceof', 'new', 'delete', 'void', 'in', 'of',
  'return', 'if', 'else', 'for', 'while', 'do', 'switch', 'case', 'break', 'continue',
  'try', 'catch', 'finally', 'throw', 'await', 'async', 'function', 'class', 'extends',
  'let', 'const', 'var', 'yield', 'this', 'super', 'default', 'debugger', 'with', 'static',
  // Runtime-injected scope that is not application state.
  'props', 'styles', 'event', 'args', 'arguments', 'window', 'document', 'fetch',
  'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', 'localStorage',
  'sessionStorage', 'location', 'navigator', 'history',
]);
 
/**
 * Statements that begin a binding.
 * @type {Set<string>}
 */
const DECLARATION_KEYWORDS = new Set(['const', 'let', 'var']);
 
/**
 * Methods that mutate the receiver rather than returning a new value.
 *
 * `cart.items.push(item)` is a write to `cart.items`, and modelling it as a
 * read would make `avenx impact cart.items` miss the code that changes it —
 * the single most important question the command answers.
 * @type {Set<string>}
 */
/**
 * Members every bridge exposes as protocol rather than declaring.
 *
 * `declaredMembers()` in BridgeParser appends the same three, so accessing one
 * is not an unknown member.
 * @type {Set<string>}
 */
export const BRIDGE_BUILTINS = new Set(['on', 'emit', '$dispose', '$name']);
 
/**
 * Collection methods that yield one element rather than another collection.
 *
 * `const item = this.items.find(...)` binds an element of `items`, so a later
 * `item.qty = n` is a write to `items`. Which element is not knowable, which
 * is why an alias resolved this way is only ever `possible`.
 * @type {Set<string>}
 */
export const ELEMENT_METHODS = new Set(['find', 'at', 'pop', 'shift']);
 
/**
 * Collection methods that yield another collection of the same elements.
 * @type {Set<string>}
 */
export const COLLECTION_METHODS = new Set(['filter', 'slice', 'concat', 'sort', 'reverse', 'flat']);
 
/**
 * Methods that mutate the receiver rather than returning a new value.
 *
 * `cart.items.push(item)` is a write to `cart.items`, and modelling it as a
 * read would make `avenx impact cart.items` miss the code that changes it —
 * the single most important question the command answers.
 * @type {Set<string>}
 */
export const MUTATING_METHODS = new Set([
  'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse', 'fill', 'copyWithin',
  'add', 'clear', 'delete', 'set',
]);
 
/**
 * Assignment operators, longest first so `??=` is matched before `?`.
 * @type {string[]}
 */
const ASSIGN_OPERATORS = ['>>>=', '<<=', '>>=', '**=', '&&=', '||=', '??=', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^='];
 
/**
 * Whether a character can start an identifier.
 * @param {string} ch - A single character.
 * @returns {boolean} True when the character may begin an identifier.
 */
function isIdentStart(ch) {
  return !!ch && /[A-Za-z_$]/.test(ch);
}
 
/**
 * Whether a character can continue an identifier.
 * @param {string} ch - A single character.
 * @returns {boolean} True when the character may continue an identifier.
 */
function isIdentPart(ch) {
  return !!ch && /[\w$]/.test(ch);
}
 
/**
 * Advances past whitespace.
 * @param {string} code - The source.
 * @param {number} i - Start offset.
 * @returns {number} The first non-whitespace offset at or after `i`.
 */
function skipSpace(code, i) {
  while (i < code.length && /\s/.test(code[i])) i++;
  return i;
}
 
/**
 * Advances past a string, template literal or comment starting at `i`.
 *
 * Returns `i` unchanged when nothing at that position opens one, which lets
 * the callers use it as an unconditional first step in their loops.
 * @param {string} code - The source.
 * @param {number} i - Start offset.
 * @returns {{end: number, interpolations: Array<{code: string, offset: number}>}}
 *   Where the construct ends, plus any `${...}` bodies found inside a template
 *   literal so the caller can scan them too.
 */
export function skipLiteral(code, i) {
  const interpolations = [];
  const ch = code[i];
 
  if (ch === '/' && code[i + 1] === '/') {
    i += 2;
    while (i < code.length && code[i] !== '\n') i++;
    return { end: i, interpolations };
  }
 
  if (ch === '/' && code[i + 1] === '*') {
    i += 2;
    while (i < code.length && !(code[i] === '*' && code[i + 1] === '/')) i++;
    return { end: Math.min(i + 2, code.length), interpolations };
  }
 
  if (ch === '"' || ch === "'") {
    const quote = ch;
    i++;
    while (i < code.length && code[i] !== quote) {
      if (code[i] === '\\') i++;
      i++;
    }
    return { end: Math.min(i + 1, code.length), interpolations };
  }
 
  if (ch === '`') {
    i++;
    while (i < code.length && code[i] !== '`') {
      if (code[i] === '\\') {
        i += 2;
        continue;
      }
      if (code[i] === '$' && code[i + 1] === '{') {
        let depth = 1;
        let j = i + 2;
        while (j < code.length && depth > 0) {
          if (code[j] === '{') depth++;
          else if (code[j] === '}') depth--;
          if (depth > 0) j++;
        }
        interpolations.push({ code: code.slice(i + 2, j), offset: i + 2 });
        i = j;
        continue;
      }
      i++;
    }
    return { end: Math.min(i + 1, code.length), interpolations };
  }
 
  return { end: i, interpolations };
}
 
/**
 * Finds the offset of the bracket closing the one at `open`.
 * @param {string} code - The source.
 * @param {number} open - Offset of the opening bracket.
 * @returns {number} Offset of the matching close, or -1 when unbalanced.
 */
function matchBracket(code, open) {
  const pairs = { '(': ')', '[': ']', '{': '}' };
  const closer = pairs[code[open]];
  if (!closer) return -1;
  let depth = 0;
  let i = open;
  while (i < code.length) {
    const literal = skipLiteral(code, i);
    if (literal.end > i) {
      i = literal.end;
      continue;
    }
    const ch = code[i];
    if (ch === '(' || ch === '[' || ch === '{') depth++;
    else if (ch === ')' || ch === ']' || ch === '}') {
      depth--;
      if (depth === 0) return i;
    }
    i++;
  }
  return -1;
}
 
/**
 * Collects every identifier bound inside a destructuring pattern.
 *
 * Handles `{ a, b: c, d = 1, ...rest }` and `[x, , y]`, including nesting.
 * Renaming (`b: c`) binds `c`, and the key `b` is reported separately as a
 * member read of the initialiser by {@link scanReferences}.
 * @param {string} pattern - The pattern text, brackets included.
 * @returns {{names: string[], keys: string[], hasRest: boolean}} What it binds.
 */
export function patternBindings(pattern) {
  const names = [];
  const keys = [];
  let hasRest = false;
 
  const isObject = pattern.trim().startsWith('{');
  const inner = pattern.trim().slice(1, -1);
 
  let depth = 0;
  let start = 0;
  const parts = [];
  for (let i = 0; i < inner.length; i++) {
    const literal = skipLiteral(inner, i);
    if (literal.end > i) {
      i = literal.end - 1;
      continue;
    }
    const ch = inner[i];
    if (ch === '{' || ch === '[' || ch === '(') depth++;
    else if (ch === '}' || ch === ']' || ch === ')') depth--;
    else if (ch === ',' && depth === 0) {
      parts.push(inner.slice(start, i));
      start = i + 1;
    }
  }
  parts.push(inner.slice(start));
 
  for (const raw of parts) {
    let part = raw.trim();
    if (part === '') continue;
 
    if (part.startsWith('...')) {
      hasRest = true;
      part = part.slice(3).trim();
    }
 
    // Strip a default value: `a = 1`. An `=` at depth zero separates them.
    let depth2 = 0;
    for (let i = 0; i < part.length; i++) {
      const ch = part[i];
      if (ch === '{' || ch === '[' || ch === '(') depth2++;
      else if (ch === '}' || ch === ']' || ch === ')') depth2--;
      else if (ch === '=' && depth2 === 0 && part[i + 1] !== '=' && part[i - 1] !== '=' && part[i + 1] !== '>') {
        part = part.slice(0, i);
        break;
      }
    }
    part = part.trim();
    if (part === '') continue;
 
    // Object shorthand vs renaming: `a` binds a; `a: b` reads a and binds b.
    if (isObject) {
      const colon = part.indexOf(':');
      if (colon !== -1) {
        const key = part.slice(0, colon).trim();
        const target = part.slice(colon + 1).trim();
        if (/^[A-Za-z_$][\w$]*$/.test(key)) keys.push(key);
        if (target.startsWith('{') || target.startsWith('[')) {
          const nested = patternBindings(target);
          names.push(...nested.names);
        } else if (/^[A-Za-z_$][\w$]*$/.test(target)) {
          names.push(target);
        }
        continue;
      }
    }
 
    if (part.startsWith('{') || part.startsWith('[')) {
      const nested = patternBindings(part);
      names.push(...nested.names);
      keys.push(...nested.keys);
      continue;
    }
 
    if (/^[A-Za-z_$][\w$]*$/.test(part)) {
      names.push(part);
      if (isObject) keys.push(part);
    }
  }
 
  return { names, keys, hasRest };
}
 
/**
 * Collects every name the body binds locally.
 *
 * Runs before reference scanning so that a use appearing textually before its
 * declaration is still treated as local. Scoping is deliberately flat: a name
 * declared anywhere in the body shadows an application declaration everywhere
 * in it. That over-approximates shadowing, which is the safe direction —
 * Atlas loses an edge and says so, rather than inventing one.
 * @param {string} code - The expression or action body.
 * @returns {Set<string>} Locally bound names.
 */
export function collectLocals(code) {
  const locals = new Set();
  if (!code) return locals;
 
  let i = 0;
  while (i < code.length) {
    const literal = skipLiteral(code, i);
    if (literal.end > i) {
      for (const part of literal.interpolations) {
        for (const name of collectLocals(part.code)) locals.add(name);
      }
      i = literal.end;
      continue;
    }
 
    // `(a, b) => ...` — a parenthesised group is a parameter list only when an
    // arrow follows it, so the group has to be matched before it can be judged.
    if (code[i] === '(') {
      const close = matchBracket(code, i);
      if (close !== -1) {
        const after = skipSpace(code, close + 1);
        if (code[after] === '=' && code[after + 1] === '>') {
          const params = code.slice(i + 1, close);
          for (const name of patternBindings(`[${params}]`).names) locals.add(name);
        }
      }
      i++;
      continue;
    }
 
    if (!isIdentStart(code[i])) {
      i++;
      continue;
    }
 
    let j = i;
    while (j < code.length && isIdentPart(code[j])) j++;
    const word = code.slice(i, j);
    const before = i > 0 ? code[i - 1] : '';
 
    // A property name is not a binding: skip `.const` and friends.
    if (before === '.') {
      i = j;
      continue;
    }
 
    if (DECLARATION_KEYWORDS.has(word)) {
      let k = skipSpace(code, j);
      // A declaration list can bind several names: `let a = 1, b = 2`.
      while (k < code.length) {
        if (code[k] === '{' || code[k] === '[') {
          const close = matchBracket(code, k);
          if (close === -1) break;
          for (const name of patternBindings(code.slice(k, close + 1)).names) locals.add(name);
          k = close + 1;
        } else if (isIdentStart(code[k])) {
          let m = k;
          while (m < code.length && isIdentPart(code[m])) m++;
          locals.add(code.slice(k, m));
          k = m;
        } else {
          break;
        }
        // Skip the initialiser to find a following comma at depth zero.
        let depth = 0;
        while (k < code.length) {
          const lit = skipLiteral(code, k);
          if (lit.end > k) {
            k = lit.end;
            continue;
          }
          const ch = code[k];
          if (ch === '(' || ch === '[' || ch === '{') depth++;
          else if (ch === ')' || ch === ']' || ch === '}') {
            if (depth === 0) break;
            depth--;
          } else if (ch === ';' && depth === 0) break;
          else if (ch === ',' && depth === 0) {
            k++;
            break;
          }
          k++;
        }
        k = skipSpace(code, k);
        if (k >= code.length || code[k] === ';' || !(isIdentStart(code[k]) || code[k] === '{' || code[k] === '[')) {
          break;
        }
      }
      i = j;
      continue;
    }
 
    if (word === 'function') {
      let k = skipSpace(code, j);
      if (isIdentStart(code[k])) {
        let m = k;
        while (m < code.length && isIdentPart(code[m])) m++;
        locals.add(code.slice(k, m));
        k = skipSpace(code, m);
      }
      if (code[k] === '(') {
        const close = matchBracket(code, k);
        if (close !== -1) {
          for (const name of patternBindings(`[${code.slice(k + 1, close)}]`).names) locals.add(name);
        }
      }
      i = j;
      continue;
    }
 
    if (word === 'catch') {
      const k = skipSpace(code, j);
      if (code[k] === '(') {
        const close = matchBracket(code, k);
        if (close !== -1) {
          for (const name of patternBindings(`[${code.slice(k + 1, close)}]`).names) locals.add(name);
        }
      }
      i = j;
      continue;
    }
 
    // A bare `x => ...` parameter.
    const after = skipSpace(code, j);
    if (code[after] === '=' && code[after + 1] === '>') {
      locals.add(word);
    }
 
    i = j;
  }
 
  return locals;
}
 
/**
 * Reads a member chain that begins at `i`.
 * @param {string} code - The source.
 * @param {number} i - Offset of the root identifier.
 * @returns {object} The chain: root, segments, flags, extent and any nested
 *   expressions found inside computed members.
 */
function readChain(code, i) {
  let j = i;
  while (j < code.length && isIdentPart(code[j])) j++;
  const root = code.slice(i, j);
 
  const segments = [];
  const nested = [];
  let dynamic = false;
  let optional = false;
 
  for (;;) {
    const k = skipSpace(code, j);
 
    if (code[k] === '?' && code[k + 1] === '.' && code[k + 2] !== '(') {
      optional = true;
      const m = skipSpace(code, k + 2);
      if (code[m] === '[') {
        const close = matchBracket(code, m);
        if (close === -1) break;
        const inner = code.slice(m + 1, close);
        const literalKey = staticKey(inner);
        if (literalKey !== null) {
          segments.push(literalKey);
        } else {
          segments.push('[]');
          if (!isNumericIndex(inner)) {
            dynamic = true;
            nested.push({ code: inner, offset: m + 1 });
          }
        }
        j = close + 1;
        continue;
      }
      if (!isIdentStart(code[m])) break;
      let n = m;
      while (n < code.length && isIdentPart(code[n])) n++;
      segments.push(code.slice(m, n));
      j = n;
      continue;
    }
 
    if (code[k] === '.') {
      const m = skipSpace(code, k + 1);
      if (!isIdentStart(code[m])) break;
      let n = m;
      while (n < code.length && isIdentPart(code[n])) n++;
      segments.push(code.slice(m, n));
      j = n;
      continue;
    }
 
    if (code[k] === '[') {
      const close = matchBracket(code, k);
      if (close === -1) break;
      const inner = code.slice(k + 1, close);
      const literalKey = staticKey(inner);
      if (literalKey !== null) {
        segments.push(literalKey);
      } else {
        segments.push('[]');
        if (!isNumericIndex(inner)) {
          dynamic = true;
          nested.push({ code: inner, offset: k + 1 });
        }
      }
      j = close + 1;
      continue;
    }
 
    break;
  }
 
  return { root, segments, dynamic, optional, start: i, end: j, nested };
}
 
/**
 * Reads a computed member key when it is a literal.
 *
 * `items['qty']` names a member as precisely as `items.qty` does, so it should
 * not be downgraded to a dynamic access. A numeric index is an element, not a
 * member, and is normalised to `[]` alongside genuinely computed keys.
 * @param {string} inner - The text between the brackets.
 * @returns {string|null} The member name, or null when the key is not a literal.
 */
function staticKey(inner) {
  const trimmed = inner.trim();
  const match = trimmed.match(/^(['"])((?:\\.|(?!\1)[^\\])*)\1$/);
  if (!match) return null;
  if (!/^[A-Za-z_$][\w$]*$/.test(match[2])) return null;
  return match[2];
}
 
/**
 * Whether a computed member is a literal element index.
 *
 * `items[0]` is fully determined, so it becomes an `[]` path segment without
 * being reported as unresolved: Atlas models declared symbols and does not
 * model individual elements, and there is nothing here it failed to follow.
 * @param {string} inner - The text between the brackets.
 * @returns {boolean} True for a numeric literal index.
 */
function isNumericIndex(inner) {
  return /^\s*\d+\s*$/.test(inner);
}
 
/**
 * Detects an assignment or update immediately following a chain.
 * @param {string} code - The source.
 * @param {number} end - Offset just past the chain.
 * @returns {{usage: string, alsoReads: boolean}|null} What was done to the chain.
 */
function readUsage(code, end) {
  const k = skipSpace(code, end);
  if (k >= code.length) return null;
 
  if (code[k] === '(') {
    return { usage: 'invoke', alsoReads: false };
  }
 
  if ((code[k] === '+' && code[k + 1] === '+') || (code[k] === '-' && code[k + 1] === '-')) {
    return { usage: 'write', alsoReads: true };
  }
 
  for (const op of ASSIGN_OPERATORS) {
    if (code.startsWith(op, k)) {
      return { usage: 'write', alsoReads: true };
    }
  }
 
  if (code[k] === '=' && code[k + 1] !== '=' && code[k + 1] !== '>') {
    return { usage: 'write', alsoReads: false };
  }
 
  return null;
}
 
/**
 * Scans expression source for member chains and what is done to them.
 * @param {string} code - The expression or action body.
 * @param {object} [options] - Scan options.
 * @param {Set<string>} [options.locals] - Names bound locally; computed when omitted.
 * @param {number} [options.base] - Offset of `code` within its containing file.
 * @returns {{references: object[], locals: Set<string>, aliases: object[], notes: object[]}}
 *   Every chain found, the local bindings, destructuring aliases and syntactic
 *   observations worth reporting (a spread, for instance).
 */
export function scanReferences(code, options = {}) {
  const locals = options.locals || collectLocals(code);
  const base = options.base || 0;
  /** @type {object[]} */
  const references = [];
  /** @type {object[]} */
  const aliases = [];
  /** @type {object[]} */
  const notes = [];
 
  if (!code) {
    return { references, locals, aliases, notes };
  }
 
  /** @type {object[]} */
  const localAliases = [];
 
  let i = 0;
  let pendingPattern = null;
  let pendingAlias = null;
 
  while (i < code.length) {
    const literal = skipLiteral(code, i);
    if (literal.end > i) {
      for (const part of literal.interpolations) {
        const sub = scanReferences(part.code, { locals, base: base + part.offset });
        references.push(...sub.references);
        aliases.push(...sub.aliases);
        notes.push(...sub.notes);
      }
      i = literal.end;
      continue;
    }
 
    // `const { a, b } = someChain` — remember the pattern so the chain that
    // follows can be recorded as a read of each destructured member.
    if (code[i] === '{' || code[i] === '[') {
      const close = matchBracket(code, i);
      if (close !== -1) {
        const after = skipSpace(code, close + 1);
        if (code[after] === '=' && code[after + 1] !== '=' && code[after + 1] !== '>') {
          pendingPattern = patternBindings(code.slice(i, close + 1));
          if (pendingPattern.hasRest) {
            notes.push({ reason: UnresolvedReason.SPREAD, expr: code.slice(i, close + 1).trim(), index: base + i });
          }
          i = after + 1;
          continue;
        }
      }
    }
 
    if (code[i] === '.' && code[i + 1] === '.' && code[i + 2] === '.') {
      notes.push({ reason: UnresolvedReason.SPREAD, expr: '...', index: base + i });
      i += 3;
      continue;
    }
 
    if (!isIdentStart(code[i])) {
      i++;
      continue;
    }
 
    // `const item = this.items.find(...)` binds a local to something that
    // comes from application state. Remembering that is what lets a later
    // `item.qty = n` be recorded as a write to `items` rather than vanishing
    // into a local nobody models.
    {
      let w = i;
      while (w < code.length && isIdentPart(code[w])) w++;
      const word = code.slice(i, w);
      if (DECLARATION_KEYWORDS.has(word)) {
        const k = skipSpace(code, w);
        if (isIdentStart(code[k])) {
          let m = k;
          while (m < code.length && isIdentPart(code[m])) m++;
          const bound = code.slice(k, m);
          const eq = skipSpace(code, m);
          if (code[eq] === '=' && code[eq + 1] !== '=' && code[eq + 1] !== '>') {
            pendingAlias = bound;
            i = eq + 1;
            continue;
          }
        }
        i = w;
        continue;
      }
    }
 
    // A property name is consumed as part of its chain, never as a new root.
    let back = i - 1;
    while (back >= 0 && /\s/.test(code[back])) back--;
    if (code[back] === '.' || (code[back] === '.' && code[back - 1] === '?')) {
      let j = i;
      while (j < code.length && isIdentPart(code[j])) j++;
      i = j;
      continue;
    }
 
    const chain = readChain(code, i);
 
    // Keywords never start a chain, but their operand does.
    if (AMBIENT_ROOTS.has(chain.root) && chain.segments.length === 0) {
      i = chain.end;
      continue;
    }
 
    for (const part of chain.nested) {
      const sub = scanReferences(part.code, { locals, base: base + part.offset });
      references.push(...sub.references);
      aliases.push(...sub.aliases);
      notes.push(...sub.notes);
    }
 
    // An object literal key (`{ qty: 1 }`) is not a reference.
    const nextIdx = skipSpace(code, chain.end);
    const isLabelled = chain.segments.length === 0 && code[nextIdx] === ':' && code[nextIdx + 1] !== ':';
    if (isLabelled) {
      i = chain.end;
      continue;
    }
 
    const usage = readUsage(code, chain.end);
    let kind = 'read';
    if (usage && usage.usage === 'write') kind = 'write';
    else if (usage && usage.usage === 'invoke') kind = 'invoke';
 
    // `--x` and `++x` mutate the chain that follows them.
    if (kind === 'read') {
      let pre = i - 1;
      while (pre >= 0 && /\s/.test(code[pre])) pre--;
      if (pre >= 1 && ((code[pre] === '+' && code[pre - 1] === '+') || (code[pre] === '-' && code[pre - 1] === '-'))) {
        kind = 'write';
      }
    }
 
    // `list.push(x)` mutates `list`. The method name leaves the path; what is
    // recorded is a write to the receiver.
    const segments = chain.segments;
    let builtinMethod = false;
    if (kind === 'invoke' && segments.length > 0 && MUTATING_METHODS.has(segments[segments.length - 1])) {
      builtinMethod = true;
      const receiver = segments.slice(0, -1);
      if (receiver.length > 0 || !AMBIENT_ROOTS.has(chain.root)) {
        references.push({
          kind: 'write',
          root: chain.root,
          segments: receiver,
          dynamic: chain.dynamic,
          optional: chain.optional,
          method: segments[segments.length - 1],
          index: base + chain.start,
          length: chain.end - chain.start,
          text: code.slice(chain.start, chain.end),
        });
      }
    }
 
    references.push({
      kind,
      root: chain.root,
      segments,
      dynamic: chain.dynamic,
      optional: chain.optional,
      index: base + chain.start,
      length: chain.end - chain.start,
      text: code.slice(chain.start, chain.end),
      ...(builtinMethod ? { builtinMethod: true } : {}),
      ...(kind === 'write' && usage && usage.alsoReads ? { alsoReads: true } : {}),
    });
 
    if (pendingPattern) {
      for (const key of pendingPattern.keys) {
        aliases.push({
          member: key,
          root: chain.root,
          segments: chain.segments,
          index: base + chain.start,
        });
      }
      pendingPattern = null;
    }
 
    if (pendingAlias) {
      localAliases.push({
        name: pendingAlias,
        root: chain.root,
        segments: chain.segments,
        index: base + chain.start,
      });
      pendingAlias = null;
    }
 
    i = chain.end;
  }
 
  return { references, locals, aliases, localAliases, notes };
}
 
/**
 * Formats a member path for display, with `[]` for element access.
 * @param {string[]} segments - The path segments.
 * @returns {string} A dotted path, e.g. `items[].qty`.
 */
export function formatPath(segments) {
  let out = '';
  for (const segment of segments) {
    if (segment === '[]') out += '[]';
    else out += out === '' ? segment : `.${segment}`;
  }
  return out;
}
 
/**
 * Maps a scanned reference onto a declaration in scope.
 *
 * The result names a **declared symbol**, not a property path: a read of
 * `cart.items[2].qty` resolves to the state key `cart.items`, and the rest of
 * the path travels alongside as metadata. Modelling it the other way — a node
 * per observed path — would invent entities the application never declared.
 * @param {object} ref - A reference from {@link scanReferences}.
 * @param {object} scope - The resolution scope.
 * @param {string} scope.ownerId - The owning node's id.
 * @param {string} scope.ownerKind - `component`, `page` or `bridge`.
 * @param {Set<string>} [scope.state] - Declared state keys.
 * @param {Set<string>} [scope.computed] - Declared computed names.
 * @param {Set<string>} [scope.actions] - Declared action names.
 * @param {Set<string>} [scope.resources] - Declared resource names.
 * @param {Map<string, object>} [scope.bridges] - Imported bridges by local name.
 * @param {Map<string, object>} [scope.loopVars] - Loop variables by name.
 * @param {Set<string>} [scope.slotProps] - Scoped-slot variable names.
 * @param {Set<string>} [scope.locals] - Locally bound names.
 * @param {object} [scope.selfBridge] - The bridge descriptor when resolving inside one.
 * @returns {{target: object|null, path: string[], confidence: string, unresolved: object|null}}
 *   The declaration reached, the remaining path, how much to trust it, and the
 *   reason nothing was reached when nothing was.
 */
export function resolveReference(ref, scope) {
  const miss = (reason, extra = {}) => ({
    target: null,
    path: ref.segments,
    confidence: Confidence.POSSIBLE,
    unresolved: { reason, expr: ref.text, ...extra },
  });
 
  const hit = (target, path, confidence = Confidence.CERTAIN) => ({
    target,
    path: path || [],
    confidence,
    unresolved: null,
  });
 
  const root = ref.root;
 
  // A loop variable stands for an element of the list it iterates, so a read of
  // `item.qty` is a read of `cart.items` — the whole point of tracking them.
  const loopVar = scope.loopVars && scope.loopVars.get(root);
  if (loopVar) {
    if (!loopVar.resolved || !loopVar.resolved.target) {
      return miss(UnresolvedReason.UNKNOWN_IDENTIFIER, { name: root });
    }
    return hit(loopVar.resolved.target, [...loopVar.resolved.path, '[]', ...ref.segments], loopVar.resolved.confidence);
  }
 
  if (scope.slotProps && scope.slotProps.has(root)) {
    return miss(UnresolvedReason.SLOT_SCOPE, { name: root });
  }
 
  // A local bound from application state carries that provenance. The result
  // is never `certain`: which element `items.find(...)` returned is not
  // knowable, only that it came from `items`.
  const alias = scope.aliases && scope.aliases.get(root);
  if (alias && alias.target) {
    return hit(alias.target, [...alias.path, ...ref.segments], Confidence.POSSIBLE);
  }
 
  // A local binding wins over an application declaration. When it shadows one,
  // that is reported: a diagnostic that concluded "never read" from a body it
  // could not follow would be worse than no diagnostic.
  if (scope.locals && scope.locals.has(root)) {
    const shadows =
      (scope.state && scope.state.has(root)) ||
      (scope.computed && scope.computed.has(root)) ||
      (scope.actions && scope.actions.has(root)) ||
      (scope.resources && scope.resources.has(root));
    if (shadows) {
      return miss(UnresolvedReason.SHADOWED_IDENTIFIER, { name: root });
    }
    return { target: null, path: ref.segments, confidence: Confidence.CERTAIN, unresolved: null };
  }
 
  // `state.count` inside an action, and `this.state.count` inside a computed,
  // reach the same declaration a bare `count` does.
  if (root === 'state' || (root === 'this' && ref.segments[0] === 'state')) {
    const offset = root === 'state' ? 0 : 1;
    const key = ref.segments[offset];
    if (!key) {
      return miss(UnresolvedReason.UNKNOWN_IDENTIFIER, { name: root });
    }
    if (scope.state && scope.state.has(key)) {
      return hit({ kind: 'state', owner: scope.ownerId, name: key }, ref.segments.slice(offset + 1));
    }
    return miss(UnresolvedReason.UNKNOWN_IDENTIFIER, { name: key });
  }
 
  if (root === 'this') {
    const member = ref.segments[0];
    if (!member) {
      return { target: null, path: [], confidence: Confidence.CERTAIN, unresolved: null };
    }
    // Inside a bridge, `this` is the bridge itself.
    if (scope.selfBridge) {
      const resolved = resolveBridgeMember(scope.selfBridge, ref.segments, scope.ownerId);
      if (resolved) return hit(resolved.target, resolved.path);
      return miss(UnresolvedReason.UNKNOWN_BRIDGE_MEMBER, { name: member });
    }
    if (scope.computed && scope.computed.has(member)) {
      return hit({ kind: 'computed', owner: scope.ownerId, name: member }, ref.segments.slice(1));
    }
    if (scope.actions && scope.actions.has(member)) {
      return hit({ kind: 'action', owner: scope.ownerId, name: member }, ref.segments.slice(1));
    }
    if (scope.state && scope.state.has(member)) {
      return hit({ kind: 'state', owner: scope.ownerId, name: member }, ref.segments.slice(1));
    }
    return miss(UnresolvedReason.UNKNOWN_IDENTIFIER, { name: `this.${member}` });
  }
 
  const bridge = scope.bridges && scope.bridges.get(root);
  if (bridge) {
    const resolved = resolveBridgeMember(bridge.descriptor, ref.segments, `bridge:${bridge.descriptor.name}`);
    if (resolved) return hit(resolved.target, resolved.path);
    if (ref.segments.length === 0) {
      return hit({ kind: 'bridge', owner: null, name: bridge.descriptor.name }, []);
    }
    return miss(UnresolvedReason.UNKNOWN_BRIDGE_MEMBER, { name: `${bridge.descriptor.name}.${ref.segments[0]}` });
  }
 
  if (scope.selfBridge) {
    const resolved = resolveBridgeMember(scope.selfBridge, [root, ...ref.segments], scope.ownerId);
    if (resolved) return hit(resolved.target, resolved.path);
  }
 
  if (scope.computed && scope.computed.has(root)) {
    return hit({ kind: 'computed', owner: scope.ownerId, name: root }, ref.segments);
  }
  if (scope.actions && scope.actions.has(root)) {
    return hit({ kind: 'action', owner: scope.ownerId, name: root }, ref.segments);
  }
  if (scope.resources && scope.resources.has(root)) {
    return hit({ kind: 'resource', owner: scope.ownerId, name: root }, ref.segments);
  }
  if (scope.state && scope.state.has(root)) {
    return hit({ kind: 'state', owner: scope.ownerId, name: root }, ref.segments);
  }
 
  if (AMBIENT_ROOTS.has(root)) {
    return { target: null, path: ref.segments, confidence: Confidence.CERTAIN, unresolved: null };
  }
 
  return miss(UnresolvedReason.UNKNOWN_IDENTIFIER, { name: root });
}
 
/**
 * Resolves a member access against a bridge's declared surface.
 *
 * The compiler already resolves these names once, to warn about members a
 * bridge does not declare (AVX_W37). Atlas keeps the answer instead of
 * discarding it, which is what lets a query cross a file boundary.
 * @param {object} descriptor - A bridge descriptor from BridgeParser.
 * @param {string[]} segments - The member path.
 * @param {string} ownerId - The bridge's node id.
 * @returns {{target: object, path: string[]}|null} The declaration, or null.
 */
export function resolveBridgeMember(descriptor, segments, ownerId) {
  if (!descriptor || segments.length === 0) return null;
  const member = segments[0];
  const rest = segments.slice(1);
 
  // `on`, `$dispose` and `$name` are the bridge protocol rather than declared
  // members. `on` in particular carries a real relationship — a subscription —
  // which the model builder records from the event name, not from this chain.
  if (BRIDGE_BUILTINS.has(member)) {
    return { target: { kind: 'bridge', owner: null, name: descriptor.name }, path: rest };
  }
 
  if (Array.isArray(descriptor.stateKeys) && descriptor.stateKeys.includes(member)) {
    return { target: { kind: 'state', owner: ownerId, name: member }, path: rest };
  }
  if (Array.isArray(descriptor.getters) && descriptor.getters.includes(member)) {
    return { target: { kind: 'getter', owner: ownerId, name: member }, path: rest };
  }
  if (Array.isArray(descriptor.actions) && descriptor.actions.includes(member)) {
    return { target: { kind: 'action', owner: ownerId, name: member }, path: rest };
  }
  return null;
}
 
export default { scanReferences, collectLocals, resolveReference, formatPath };