Keep declarations a removed branch shares with another

This commit is contained in:
Chris Tate
2026-10-26 07:50:59 -05:00
parent de17158254
commit 52a228d0f5
5 changed files with 38 additions and 16 deletions
@@ -502,15 +502,18 @@ export function planShapeOverlay(
// Dead branches: outermost first; edits inside a replaced range drop.
const dead = (deadByFile.get(sf) ?? []).sort((a, b) => a.pos - b.pos);
const ranges: [number, number][] = [];
const hoisted = new Map<number, readonly string[]>();
const removed = new Set<ts.Node>();
for (const branch of dead) {
const start = ts.tokenStartOfNode(branch);
if (ranges.some(([lo, hi]) => start >= lo && start < hi)) continue;
const names = hoistedNames(branch);
if (names === null) continue;
hoisted.set(start, names);
if (hoistedNames(branch, removed) === null) continue;
removed.add(branch);
ranges.push([start, branch.end]);
}
// A var that only removed branches declare stays declared in each.
const hoisted = new Map<number, readonly string[]>();
for (const branch of removed)
hoisted.set(ts.tokenStartOfNode(branch), hoistedNames(branch, removed)!);
if (ranges.length > 0) {
const kept = edits.filter((e) => !ranges.some(([lo, hi]) => e.pos > lo && e.pos < hi));
edits.length = 0;
@@ -664,11 +667,11 @@ function neutralizeComment(comment: string): string {
}
/** The `var` names a dead branch declares that its function (or file)
* declares nowhere else: replacing the branch must keep them bound, as
* undefined. Null when the branch declares a function in a block, whose
* binding outside the block depends on the file's strictness; such a
* branch is kept. */
function hoistedNames(branch: ts.Node): readonly string[] | null {
* declares nowhere else outside the `removed` branches: replacing the
* branch must keep them bound, as undefined. Null when the branch declares
* a function in a block, whose binding outside the block depends on the
* file's strictness; such a branch is kept. */
function hoistedNames(branch: ts.Node, removed: ReadonlySet<ts.Node>): readonly string[] | null {
let scope: ts.Node | undefined = branch.parent;
while (scope !== undefined && !ts.isSourceFile(scope) && !functionScope(scope))
scope = scope.parent;
@@ -702,7 +705,7 @@ function hoistedNames(branch: ts.Node): readonly string[] | null {
const body = ts.isSourceFile(scope) ? scope : (scope as { body?: ts.Node }).body;
if (body !== undefined)
ts.walkPreorder(body, (n) => {
if (n === branch) return "skip";
if (n === branch || removed.has(n)) return "skip";
if (n !== body && ts.isFunctionDeclaration(n) && n.name !== undefined)
elsewhere.add(n.name.text);
if (n !== body && (functionScope(n) || ts.isClassDeclaration(n) || ts.isClassExpression(n)))
+4 -2
View File
@@ -1,6 +1,7 @@
// The shape overlay's facts: results that include undefined, literals that
// hold comment-like text, and a var declared in a branch no call takes.
import { banner, hoisted, lookup, lookupOrReturn } from "shape-facts";
// hold comment-like text, a var declared in a branch no call takes, and
// a var declared in two such branches.
import { banner, hoisted, lookup, lookupOrReturn, shared } from "shape-facts";
console.log(lookup("b"));
console.log(lookup("z"));
@@ -8,3 +9,4 @@ console.log(lookupOrReturn("a"));
console.log(lookupOrReturn("y"));
console.log(banner(7));
console.log(hoisted("note:"));
console.log(shared("level:"));
@@ -2,3 +2,4 @@ export declare function lookup(key: string): string;
export declare function lookupOrReturn(key: string): string;
export declare function banner(n: number): string;
export declare function hoisted(label: string): string;
export declare function shared(label: string): string;
@@ -1,6 +1,7 @@
// Functions whose results include the undefined of a fall-through or a
// bare return, comment-like text in literals, and a branch no call takes
// that declares a var read outside it.
// bare return, comment-like text in literals, a branch no call takes
// that declares a var read outside it, and two such branches that declare
// the same var.
const entries = [
{ key: "a", value: 1 },
@@ -53,3 +54,17 @@ function noteOf(extra) {
export function hoisted(label) {
return label + " " + noteOf(undefined);
}
function levelOf(warn, fail) {
if (warn) {
var level = "warn";
}
if (fail) {
var level = "fail";
}
return typeof level + " " + String(level);
}
export function shared(label) {
return label + " " + levelOf(undefined, undefined);
}
+3 -2
View File
@@ -63,7 +63,8 @@ test("shapeOverlay: false compiles without the overlay", { timeout: 900_000 }, a
// shape-facts: results that include the undefined of a fall-through or a bare
// return keep the caller's falsy branch, comment-like text in literals keeps
// its value, and a replaced branch keeps its var declared.
// its value, and a replaced branch keeps its var declared, also when two
// replaced branches declare the same var.
const factsEntry = join(repoRoot, "tests/fixtures/npm/cases/shape-overlay-facts/main.ts");
test(
@@ -78,7 +79,7 @@ test(
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
const report = (result.warnings ?? []).find((w) => w.code === "SC6103");
expect(report?.message).toMatch(/of 1 package \(shape-facts\)/);
expect(report?.message).toMatch(/replaces 1 branch it proved unreachable/);
expect(report?.message).toMatch(/replaces 3 branches it proved unreachable/);
const native = await execFileAsync(result.binaryPath, [], {
cwd: dirname(factsEntry),
encoding: "utf8",