Skip to content

JS/TS: functions published on window/globalThis are dropped, and .astro script blocks are parsed unmasked #3337

Description

@MK023

Two related defects that make browser entry points invisible in the graph. Found while using graphify to orient in an Astro site whose Cloudflare Turnstile callbacks are published on window — the graph showed the callbacks as absent, which read as dead code.

Version: 0.9.53 (latest on PyPI at time of writing).

1. A function assigned to a global receiver produces no node

// entry.js
function helper() { return 1; }
const arrow = () => 2;
window.onTurnstileOk = (token) => { helper(); };
globalThis.__DEBUG_HOOK__ = function () { return 3; };
$ python -c "from graphify.extract import extract; from pathlib import Path; \
  print([n['label'] for n in extract([Path('entry.js')], cache_root=Path('.'))['nodes']])"
['entry.js', 'helper()', 'arrow()']

_js_member_assignment_target (graphify/extractors/engine.py) classifies the receiver and materializes a node only for exports.X and Foo.prototype.X. An arbitrary identifier receiver returns ("object", name, member), and both call sites drop it unless the receiver is a local object-literal binding — deliberately, per the #1077 phantom-god-node guard.

The guard is right in general and wrong for the global object specifically. Assigning a function to window / globalThis / self is how browser code publishes an entry point: an inline onload=, a data-callback= attribute, a third-party script that invokes it by name (Turnstile, reCAPTCHA, Google Maps), a debug hook. The call site is outside the module graph by construction, so the definition already has no incoming edge — dropping the node too means live code reads as absent.

The #1077 collision concern is answered by the id, not by the drop: qualifying with the file stem and the receiver keeps two files publishing the same global name as two nodes, exactly as the exports branch already does.

2. .astro is handed to the JS grammar unmasked

extract_astro calls _extract_generic(path, _JS_CONFIG) on the whole file, so tree-sitter errors out on the template and the <script> block's symbols survive only as far as error recovery carries them — which varies with what precedes them. On a real 750-line component: 9 nodes, and warning: first error at line 1.

.vue already solves this by blanking every non-<script> region before parsing (_vue_mask_non_script). .astro has the same shape — TS frontmatter plus <script> blocks — so it can take the same treatment.

Worth flagging for whoever implements it: scanning for <script> across the whole file finds the one in this frontmatter comment

---
// keep this in sync, do not duplicate the labels inside the <script>.
---

which then "closes" at the real </script> 580 lines below and swallows the entire client-side region. Scanning only after the frontmatter avoids it. (This bit me while writing the fix; there is a regression test for it in the PR.)

Measured on a real repository

Three files from a production Astro site, before → after both fixes:

file nodes edges globals found
NeonTerminal.astro 20 → 31 38 → 55 window.askTurnstileOk, window.askTurnstileErr
Servizi.astro 9 → 17 20 → 27 window.svcTurnstileOk, window.svcTurnstileErr
worker/sentry.js 2 → 2 3 → 3 globalThis.__SEGNALA_SENTRY__

The warning: ... syntax errors on both .astro files is gone.

PR follows, with 5 tests: the two receiver cases (module level and inside a setup function), file-scoped ids, .astro script symbols, and the frontmatter-comment regression. Full suite unchanged: same 24 pre-existing failures (test_terraform.py, test_skillgen.py) before and after, 4913 → 4918 passed.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions