mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-15 05:59:11 +08:00
Issue #2300: a function written inside an object literal became a symbol
only when the object was an `export const`. A plain `const api = {...}`,
an object declared inside an IIFE or a function, and a namespace hung on
the page (`window.WS = {...}`, `ns.mod = {...}`) produced no member nodes,
and no node for `WS` either. Calls made inside those members were credited
to the enclosing constant or lost, and calls into them resolved to
nothing. Script-tag JavaScript, written almost entirely this way, was
mostly missing from callers and impact.
Cause: the TS/JS extractor, and the kernel's tsjs mirror of it, minted
members only for exported object-of-functions; any other literal was
walked as one opaque initializer, and an assignment to a member path was
never a declaration. Resolution had no way to reach a member through
`App.init()`, `window.App.init()` or `App.utils.pad()`.
Fix (extraction, TS and kernel byte-identical): a named object literal
owns its function members (method shorthand, `key: function`, `key: () =>`,
generators; static keys only), whether it is declared at module scope,
in a function body or IIFE, assigned to a path (`window.App = {...}`,
`App.utils = {...}`) or assigned to a plain name at module level
(`dw_page = {...}`). Members are `function` nodes qualified under the owner
(`api::load`, `window.App::init`, `App.utils::pad`), the exported case
included; a path owner is named by its last link and qualified by the
path. Calls in a member are the member's; other values are walked where
they were before. CommonJS exports, prototypes, `this.x = {...}`,
call-argument literals, a name reassigned inside a function, and
generated or minified files keep the old shape. Minified files are now
also recognised by content, in both extractors, and the kernel's
`.min.js` pattern matches the TS one.
Fix (resolution): a member is reached through its object only:
`App.init()`, `window.App.init()`, `App.utils.pad()`, a sibling's
`this.render()`, a `const { init } = App` binding; never a bare `init()`.
A same-file holder is chosen by lexical block (an IIFE's own `App` first);
otherwise a global one (`window.App = {...}`, or a classic script's
top-level `App`) when the caller neither imports nor binds the name.
Arrow members keep the `this` of the method around the literal, in the
`this.x` resolvers too. A dotted-path holder is never reached by its last
name alone, and Svelte's `$store` rule now applies only in `.svelte`
components, so the new local holders are not taken for `$n` in plain
scripts. The lookups read each file once (destructuring patterns only
when the raw text has a `} =`) and keep only the last 32 files' scans.
Contributor PR #2310 (@danusha2345): adopted its model (owner-qualified
members exported or not, path owners, IIFE/local owners, static keys,
no bare-name reach, `<script setup>` contains edges, SFC languages, test
scenarios), re-implemented on current main. Not taken: the
EXTRACTION_VERSION bump (already 28), the extraction-time binding oracle
with its ref/edge metadata and kernel ref patching (replaced by
resolution-time lookups), naming the owner `window.WS`, rewriting
`window.X.m()` ref names, flipping exported members' isExported, and
unrelated bare-call changes.
Verification: new js-object-literal-members suite (native + wasm, 18
tests); the issue's shapes fail on main and the guard test fails without
the guards. kernel-tsjs-parity passes with CODEGRAPH_KERNEL_EXPECT=1
(object-literal owners LF/CRLF, minified bundles); kernel parity sweeps
show 0 diffs on DokuWiki, vue-realworld, TodoMVC, excalidraw and this repo.
Validation (kernel loaded, before = origin/main 023fc311):
- DokuWiki: nodes +85, calls +105; 17 removed edges = 13 moved to the
member, 4 re-resolved from a wrong LinkWizard::init to the right init.
- TodoMVC: nodes +427, calls +148; 306 removed = 226 moved, 33
re-resolved (14 fixed `this.render()`/`this.save()`/`this.track()`, 19
wrong-to-wrong fallback guesses), 47 dropped wrong edges (43 to a
helper local to jQuery Mobile's scrollstart setup).
- excalidraw: nodes +86, calls +75; 34 removed = 33 moved, 1 fuzzy 0.3
guess dropped.
- vue-realworld: nodes +10, calls +11; 7 calls re-resolved from the
ApiService constant to its members.
New self-loops are real recursion, bar one lazily redefined method that
calls itself as written. 81 sampled added edges, 80 correct
(the other is main's own window.open guess, re-attributed). Whole-run
index time, median of 3 interleaved: vue-realworld 1.2s -> 1.2s,
DokuWiki 8.6s -> 9.1s, excalidraw 10.8s -> 10.7s, TodoMVC 16.9s -> 16.9s
(within run-to-run noise).
Co-authored-by: danusha2345 <ewidusoc498@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
121 lines
4.9 KiB
TypeScript
121 lines
4.9 KiB
TypeScript
/**
|
|
* How a single-file component's script joins the component — shared by the
|
|
* Vue, Svelte and Astro extractors, which hand each `<script>` block (or
|
|
* Astro's frontmatter) to the TypeScript extractor and fold its result back.
|
|
*
|
|
* The fold used to keep the block's own file node and make the component
|
|
* contain it AND every node in the block, so the hierarchy ran file-inside-
|
|
* component and every member had two parents: the Symbol view listed a
|
|
* component's script twice, with the component and its file among its own
|
|
* members. And everything the block did at its top level stayed on that file
|
|
* node — but `<script setup>`, a Svelte instance script and Astro's
|
|
* frontmatter run once per component instance, so their top-level calls ARE
|
|
* the component's doing: a Nuxt page's `useAsyncData(…)`, a Svelte page's
|
|
* `onMount(…)`. With them on the file, a walk from the component found
|
|
* nothing, and the viewer's Steps tab drew a screen alone.
|
|
*
|
|
* Now: the SFC has one file node, the whole file, holding the component; what
|
|
* the block holds at its top level the component holds, and a nested symbol
|
|
* keeps its own parent; a per-instance block's top-level calls and references
|
|
* — a top-level constant's initializer's included — are the component's,
|
|
* while its imports, and everything a module-level block does, stay with the
|
|
* file.
|
|
*/
|
|
|
|
import type { Edge, ExtractionError, ExtractionResult, Language, Node, UnresolvedReference } from '../types';
|
|
import * as path from 'path';
|
|
|
|
/** The SFC's own file node — the whole file, as the tree-sitter extractor makes one. */
|
|
export function sfcFileNode(filePath: string, source: string, language: Language): Node {
|
|
return {
|
|
id: `file:${filePath}`,
|
|
kind: 'file',
|
|
name: path.basename(filePath),
|
|
qualifiedName: filePath,
|
|
filePath,
|
|
language,
|
|
startLine: 1,
|
|
endLine: source.split('\n').length,
|
|
startColumn: 0,
|
|
endColumn: 0,
|
|
isExported: false,
|
|
updatedAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
export interface ScriptFold {
|
|
filePath: string;
|
|
componentNodeId: string;
|
|
/** Lines before the block in the SFC file: the block's line 1 is `lineOffset + 1`. */
|
|
lineOffset: number;
|
|
language: Language;
|
|
/** The block runs once per component instance — Vue `<script setup>`, a Svelte instance script, Astro frontmatter. */
|
|
perInstance: boolean;
|
|
}
|
|
|
|
export interface ScriptSink {
|
|
nodes: Node[];
|
|
edges: Edge[];
|
|
unresolvedReferences: UnresolvedReference[];
|
|
errors: ExtractionError[];
|
|
}
|
|
|
|
/** What a per-instance block does at its top level, as opposed to what it imports. */
|
|
const INSTANCE_REF_KINDS: ReadonlySet<string> = new Set(['calls', 'references', 'instantiates', 'function_ref']);
|
|
|
|
export function foldScriptResult(result: ExtractionResult, fold: ScriptFold, sink: ScriptSink): void {
|
|
const blockFile = `file:${fold.filePath}`;
|
|
// What already has a parent inside the block — a method's class, a nested
|
|
// function's function. The block's file holding something is not a parent:
|
|
// the component is, now.
|
|
const parented = new Set<string>();
|
|
for (const edge of result.edges) {
|
|
if (edge.kind === 'contains' && edge.source !== blockFile) parented.add(edge.target);
|
|
}
|
|
|
|
// A per-instance block's top-level `const data = useFetch(…)` is component
|
|
// state: its initializer runs on every instance, as the component's doing.
|
|
const instanceValues = new Set<string>();
|
|
if (fold.perInstance) {
|
|
for (const node of result.nodes) {
|
|
if ((node.kind === 'constant' || node.kind === 'variable') && !parented.has(node.id)) instanceValues.add(node.id);
|
|
}
|
|
}
|
|
const runsAsComponent = (id: string) => id === blockFile || instanceValues.has(id);
|
|
|
|
for (const node of result.nodes) {
|
|
if (node.kind === 'file') continue;
|
|
node.startLine += fold.lineOffset;
|
|
node.endLine += fold.lineOffset;
|
|
node.language = fold.language;
|
|
sink.nodes.push(node);
|
|
if (!parented.has(node.id)) sink.edges.push({ source: fold.componentNodeId, target: node.id, kind: 'contains' });
|
|
}
|
|
|
|
for (const edge of result.edges) {
|
|
if (edge.kind === 'contains' && edge.source === blockFile) continue;
|
|
if (edge.line) edge.line += fold.lineOffset;
|
|
// What a top-level value DOES is the component's; what it HOLDS stays its
|
|
// own — `const api = { load() {…} }` keeps `api::load` (#2300).
|
|
if (fold.perInstance && runsAsComponent(edge.source) && edge.kind !== 'imports' && edge.kind !== 'contains') {
|
|
edge.source = fold.componentNodeId;
|
|
}
|
|
sink.edges.push(edge);
|
|
}
|
|
|
|
for (const ref of result.unresolvedReferences) {
|
|
ref.line += fold.lineOffset;
|
|
ref.filePath = fold.filePath;
|
|
ref.language = fold.language;
|
|
if (fold.perInstance && runsAsComponent(ref.fromNodeId) && INSTANCE_REF_KINDS.has(ref.referenceKind)) {
|
|
ref.fromNodeId = fold.componentNodeId;
|
|
}
|
|
sink.unresolvedReferences.push(ref);
|
|
}
|
|
|
|
for (const error of result.errors) {
|
|
if (error.line) error.line += fold.lineOffset;
|
|
sink.errors.push(error);
|
|
}
|
|
}
|