Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md

Large diffs are not rendered by default.

128 changes: 128 additions & 0 deletions __tests__/default-export-declaration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,97 @@ export default class AnimatedColor {
const { default: Color } = require("./AnimatedColor")
return [new AnimatedColor("red"), new Color("blue")]
}
`,
// An `export default` line written in a template literal is text, not the
// module's statement (#2968). A code generator's template names the file's
// own `App`, above the real default — the issue's own example first.
'src/gen/issue.ts': `export function App() { return 1; }
export function Main() { return 2; }
export const template = \`
export default App
\`;
export default Main;
`,
// Returned by a function, behind a template nested in an interpolation.
'src/gen/render.ts': `export function App() { return 1 }
export function Main() { return 2 }
export function render() {
return \`intro \${\`nested\`}
export default App
\`
}
export default Main
`,
// Passed at module scope, where no declaration encloses it, and a class field.
'src/gen/write.ts': `import { writeFileSync } from "fs"
export function App() { return 1 }
export function Main() { return 2 }
writeFileSync("out.ts", \`
export default App
\`)
export class Emitter {
sample = \`
export default App
\`
}
export default Main
`,
// A template's anonymous default made the expression default below it
// anonymous too, which keeps every guess away.
'src/gen/model.ts': `export class Model {
save() {
return 1
}
}
export const usage = \`
export default class extends Model {}
\`
export default observable(Model)
function observable<T>(target: T): T {
return target
}
`,
// No template text: a regex and a string that hold a backtick, and a
// division after a parenthesis.
'src/gen/plain.ts': `export function App() { return 1 }
export function Main() { return 2 }
const tick = /[\`]/g
const half = (App() + Main()) / 2 + "\`"
export default Main
`,
// A regex right after `if (…)` reads as a division, so its backtick opens a
// literal the scan never closes: then the scan vouches for nothing, and the
// statement still counts.
'src/gen/unbalanced.ts': `export function App() { return 1 }
export function Main() { return 2 }
export function check(s: string, ok: boolean) {
if (ok) /\`/.test(s)
return s
}
export default Main
`,
'src/gen/multi.ts': `export function helper() { return 1; }
export default (
value
) => value;
`,
'src/gen/use.ts': `import Issue from "./issue"
import Render from "./render"
import Write from "./write"
import Persisted from "./model"
import Plain from "./plain"
import Unbalanced from "./unbalanced"
import Multi from "./multi"

export function generate() {
Issue()
Render()
Write()
new Persisted()
Plain()
Unbalanced()
Multi()
}
`,
'src/use.tsx': `import handler from "./api/vans"
import Store from "./store"
Expand Down Expand Up @@ -333,3 +424,40 @@ describe('a default import of a module that exports something above its default'
expect(consumed()).toContain('src/service.ts:run');
});
});

describe('an `export default` line inside a template literal', () => {
/** `file:name` of every node `generate` calls or instantiates. */
function generated(): string[] {
const generate = cg.getNodesInFile('src/gen/use.ts').find((n) => n.name === 'generate')!;
return cg
.getOutgoingEdges(generate.id)
.filter((e) => e.kind === 'calls' || e.kind === 'instantiates')
.map((e) => cg.getNode(e.target)!)
.map((n) => `${n.filePath}:${n.name}`)
.sort();
}

it('is text, so the real statement below it names the default', () => {
const targets = generated();
for (const file of ['issue', 'render', 'write']) {
expect(targets).toContain(`src/gen/${file}.ts:Main`);
expect(targets).not.toContain(`src/gen/${file}.ts:App`);
}
});

it('does not make an expression default anonymous', () => {
expect(generated()).toContain('src/gen/model.ts:Model');
});

it('binds a multiline anonymous arrow default to nothing, not to a function beside it', () => {
expect(generated()).not.toContain('src/gen/multi.ts:helper');
});

it('leaves regexes, strings and division alone, and a scan it cannot finish', () => {
const targets = generated();
expect(targets).toContain('src/gen/plain.ts:Main');
expect(targets).toContain('src/gen/unbalanced.ts:Main');
expect(targets).not.toContain('src/gen/plain.ts:App');
expect(targets).not.toContain('src/gen/unbalanced.ts:App');
});
});
224 changes: 224 additions & 0 deletions __tests__/python-receiver-rebinding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/**
* A Python call through a name the file imports as a module reads that module
* only where nothing nearer binds the name:
*
* from pkg import other
*
* def real():
* other.update({}) # pkg/other.py's update
*
* def by_param(other):
* other.update({}) # dict.update, or whatever the caller passed
*
* The import resolver read the file's imports alone, so a parameter, a local,
* a `with … as` / `except … as` / loop target, a walrus, a `case` capture or a
* lambda or comprehension variable named like the module still linked the
* call to the module's function. A function-local `from pkg import third as
* other` linked to `pkg.other` too, and so did, from every other function of
* the file, though it binds the name only where it is written.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';

const FILES: Record<string, string> = {
'pyproject.toml': '[project]\nname = "pkg"\n',
'src/pkg/__init__.py': '',
'src/pkg/other.py': [
'def update(x=None):',
' return x',
'',
'',
'def persist(x=None):',
' return x',
'',
'',
'def get(key=None):',
' return key',
'',
].join('\n'),
'src/pkg/third.py': ['def get(key=None):', ' return key', ''].join('\n'),
'tests/shadow.py': [
'from pkg import other',
'',
'',
'def real():',
' other.update({"b": 2})',
' return other.persist(1)',
'',
'',
'def by_param(other):',
' other.update({"a": 1})',
' return other.persist(2)',
'',
'',
'def by_with(path):',
' with open(path, "w") as other:',
' return other.persist(3)',
'',
'',
'def by_assign():',
' other = {}',
' other.update({"c": 3})',
'',
'',
'def by_for(items):',
' for other in items:',
' other.persist(4)',
'',
'',
'def by_except():',
' try:',
' pass',
' except OSError as other:',
' return other.persist(5)',
'',
'',
'def by_lambda():',
' return lambda other: other.persist(6)',
'',
'',
'def by_comprehension(items):',
' return [other.persist(7) for other in items]',
'',
'',
'def by_walrus(load):',
' if (other := load()):',
' return other.persist(8)',
'',
'',
'def by_closure(other):',
' def inner():',
' return other.persist(9)',
' return inner',
'',
'',
'def by_capture(v):',
' match v:',
' case other:',
' return other.persist(10)',
'',
'',
'def by_local_import():',
' from pkg import third as other',
' return other.get(11)',
'',
'',
// Each of these reads the module the file imports.
'def keyword_argument(run):',
' run(',
' other=1,',
' )',
' return other.persist(12)',
'',
'',
'def in_a_string():',
' print("with x as other", "other = 1") # other = 2',
' return other.persist(13)',
'',
'',
'def comprehension_of_others(items):',
' return [x for x in other.persist(items)]',
'',
'',
'def lambda_beside(run):',
' return run(lambda other: 1, other.persist(14))',
'',
'',
'def declared_global():',
' global other',
' return other.persist(15)',
'',
'',
'def keyword_pattern(v):',
' match v:',
' case Point(other=1):',
' return other.persist(16)',
'',
'',
'def same_module_again():',
' from pkg import other',
' return other.get(17)',
'',
].join('\n'),
// The module binds `other` with a plain import, which the file's mappings
// list after every from-import, the function-local one included.
'tests/leak.py': [
'import pkg.other as other',
'',
'',
'def local_third():',
' from pkg import third as other',
' return other.get(1)',
'',
'',
'def untouched():',
' return other.get(2)',
'',
].join('\n'),
};

describe.each(['LF', 'CRLF'])('a Python receiver the function binds itself is not the module the file imports (%s)', (eol) => {
let root = '';
let cg: CodeGraph | undefined;

beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-py-rebind-'));
for (const [rel, content] of Object.entries(FILES)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), eol === 'CRLF' ? content.replace(/\n/g, '\r\n') : content);
}
cg = await CodeGraph.init(root, { index: true });
}, 60_000);

afterAll(() => {
cg?.destroy();
if (root) fs.rmSync(root, { recursive: true, force: true });
});

/** `file:name` of each project function `name` in `file` calls, sorted. */
const callsFrom = (file: string, name: string): string[] => {
const from = cg!.getNodesInFile(file).filter((n) => n.name === name && (n.kind === 'function' || n.kind === 'method'));
expect(from, `${name} in ${file}`).toHaveLength(1);
return cg!.getOutgoingEdgesFrom([from[0]!.id])
.filter((e) => e.kind === 'calls')
.map((e) => cg!.getNode(e.target)!)
.filter((t) => t.filePath.startsWith('src/'))
.map((t) => `${t.filePath}:${t.name}`)
.sort();
};

it('a call where nothing nearer binds the name reaches the module', () => {
expect(callsFrom('tests/shadow.py', 'real')).toEqual(['src/pkg/other.py:persist', 'src/pkg/other.py:update']);
});

it.each([
'by_param', 'by_with', 'by_assign', 'by_for', 'by_except', 'by_lambda',
'by_comprehension', 'by_walrus', 'inner', 'by_capture',
])('%s: a name the code around the call binds links no module function', (fn) => {
expect(callsFrom('tests/shadow.py', fn)).toEqual([]);
});

it('a function-local `from pkg import third as other` is that module', () => {
expect(callsFrom('tests/shadow.py', 'by_local_import')).toEqual(['src/pkg/third.py:get']);
expect(callsFrom('tests/leak.py', 'local_third')).toEqual(['src/pkg/third.py:get']);
});

it('a function-local import binds the name in that function only', () => {
expect(callsFrom('tests/leak.py', 'untouched')).toEqual(['src/pkg/other.py:get']);
});

it.each([
['keyword_argument', 'src/pkg/other.py:persist'],
['in_a_string', 'src/pkg/other.py:persist'],
['comprehension_of_others', 'src/pkg/other.py:persist'],
['lambda_beside', 'src/pkg/other.py:persist'],
['declared_global', 'src/pkg/other.py:persist'],
['keyword_pattern', 'src/pkg/other.py:persist'],
['same_module_again', 'src/pkg/other.py:get'],
])('%s binds nothing of that name: the call reaches the module', (fn, target) => {
expect(callsFrom('tests/shadow.py', fn)).toEqual([target]);
});
});
Loading
Loading