8000 hasMutableExportedBinding skips destructured declarations, so a live exported binding still gets settled · Issue #74 · colinhacks/zshy · GitHub
[go: up one dir, main page]

Skip to content

hasMutableExportedBinding skips destructured declarations, so a live exported binding still gets settled #74

Open

Description

@Novohudonossor

Reading through the sealCjsExports work from #73, I think hasMutableExportedBinding misses destructured declarations, which lets exactly the case it guards against through.

src/compile.ts:148:

function hasMutableExportedBinding(source: ts.SourceFile): boolean {
  const mutableLocals = new Set<string>();
  for (const statement of source.statements) {
    if (!ts.isVariableStatement(statement)) continue;
    const isConst = (statement.declarationList.flags & ts.NodeFlags.Const) !== 0;
    if (isConst) continue;
    const exported = ts.getModifiers(statement)?.some(m => m.kind === ts.SyntaxKind.ExportKeyword);
    for (const declaration of statement.declarationList.declarations) {
      if (!ts.isIdentifier(declaration.name)) continue;   // <-- binding patterns skipped
      if (exported) return true;
      mutableLocals.add(declaration.name.text);
    }
  }
  ...
}

The isIdentifier check sits before both branches, so any binding pattern is dropped early. That misses two shapes:

export let { counter } = obj;      // never returns true
let { counter } = obj;             // counter never lands in mutableLocals,
export { counter };                // so the named-export scan below misses it too

Either way hasMutableExportedBinding returns false, settling stays enabled, and the accessor gets snapshotted. That produces the divergence the comment right above the function describes: require(pkg).counter keeps reporting the load-time value while import { counter } reports the current one.

const destructuring is correctly irrelevant here — it's filtered by the isConst check above. The gap is only for let/var binding patterns.

Treating a non-const declaration with a binding pattern as mutable would close the first shape; collecting the destructured element names into mutableLocals would close the second.

I have not built a package to reproduce this end to end — it's a read of the code as merged, and the same lines are on main now (src/compile.ts:156). If destructured exports are outside what zshy intends to support, this is not worth your time.


To be upfront about where this came from: I found it with a code review tool I am building (ReviewGate) while running it over real repositories, then read the function and checked each shape against it by hand before writing this up. Close it if I have misread the intent.

Activity

  1. pullfrog commented on Sep 8, 2026

    @pullfrog
    Contributor
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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions

      0