jsconfuser: new plugin - #117
Draft
echo094 wants to merge 128 commits into
Draft
Conversation
echo094
force-pushed
the
jsconfuser
branch
2 times, most recently
from
September 22, 2024 09:57
03a40a2 to
cd2859e
Compare
einstein95
reviewed
Jan 16, 2025
|
echo094
force-pushed
the
jsconfuser
branch
3 times, most recently
from
January 31, 2025 17:22
23f2641 to
215e6d6
Compare
echo094
force-pushed
the
main
branch
3 times, most recently
from
June 27, 2026 00:17
f6677df to
ddcd947
Compare
Contributor
Babel 8 ships native ESM, so `import generator from '@babel/generator'` (and traverse) resolve to the function directly, and the CJS-interop shim's `_generate.default` is now undefined. c8a97a6 already made this change everywhere else in the codebase; the jsconfuser plugin and its visitors were missed, so invoking `-t jsconfuser` threw `TypeError: traverse is not a function` immediately - invisible to the test suite, which exercises these visitors directly and never through the plugin's own entry point. Verified against real high-preset encoder output, not just the test suite, since that's exactly the gap that hid this. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Reverses the wrapper Flatten leaves at a function's original position (a flat-object proxy passed to an extracted, closure-severed function) by structurally matching the wrapper and flat-object-property shapes - no identifier-name assumptions, since RenameVariables scrubs Flatten's placeholder names before real obfuscated code reaches a decoder. Handles FunctionDeclaration, FunctionExpression, and object/class methods, the strict-mode arguments-destructure parameter fallback, and nested/chained flattening (an inner function's already-flattened call can itself get re-proxied by an enclosing function's own flatten pass; inlining unwinds this by recursing into each freshly rebuilt body). Wired in after the ControlFlowFlattening decode step in the jsconfuser pipeline, since Flatten runs earliest on the encode side and its output is reshaped by every later transform. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…k-wrapped dePack returned undefined (bare `return`) whenever the last Program statement didn't match the Pack eval-wrapper shape, and jsconfuser.js unconditionally reassigns `ast = jcPack(ast)` - so any jsconfuser sample obfuscated without `pack: true` crashed the whole plugin before reaching any other decode step. Masked until now because every prior end-to-end verification happened to use pack:true (high preset) samples. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
binding.constantViolations was read without checking binding itself, crashing whenever the assignment target has no resolvable binding (e.g. an undeclared global like `TEST_OUTPUT = "..."`, common in obfuscator test fixtures across this whole project). Only checked `!name` before, not `!binding`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…orms Integrity (Order 37, the encoder's last stage): relocates a hashed function's real params/body back onto its original name from the sibling forwarder Order.Lock's first pass + Order.Integrity's second pass leave behind, and transitively cleans up the now-dead hash-utility chain (HashTemplate's wrapper -> low-level cyrb53 fn -> imul var/ polyfill) once nothing still calls it. Wired in right after Pack, the mirror image of Flatten's placement: Integrity is encoded last, so unlike everything else in this pipeline its output is never reshaped by a later encoder transform, making it the least-processed input this decoder sees. Lock (Order 3) is only partially covered here - antiDebug (bare `debugger;`), selfDefending (the self-toString()-checking IIFE), and cleanup of the invokeCountermeasures/hasInvoked dispatch wrapper once nothing decoded still calls it (deferred to Program exit and re-checked via safeDeleteNode, since dateLock/domainLock/tamperProtection calls to the same wrapper are not decoded by this pass and must not be assumed dead). dateLock, domainLock, and tamperProtection are deliberately out of scope: Order.Lock runs early on the encode side, so unlike antiDebug/ selfDefending, their load-bearing literals (a timestamp, a regex string) and the ~60-line tamperProtection prelude are meaningfully more exposed to reshaping by nearly every later transform before reaching real output, and need their own follow-up pass. Wired in late, right before Flatten, for the same reason Flatten itself runs last. Tests: test/visitor/jsconfuser/integrity/ (single hashed function with its full hash-utility chain, a named-countermeasures variant confirming invokeCountermeasures cleanup is correctly left to lock.js, two hashed functions sharing one hash fn to confirm deferred/transitive cleanup only fires once both are decoded, and a guard case) and test/visitor/jsconfuser/lock/ (antiDebug and selfDefending at both program and function-body depth, invokeCountermeasures cleanup firing once its only call site is gone, a "still live" case confirming cleanup correctly withholds when a simulated not-yet-decoded guard still calls it, and a selfDefending near-miss left untouched). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…urrent encoder
Both were marked "Covered" in decode-nexus's coverage map but turned out
completely non-functional against the current pinned js-confuser
source - confirmed empirically before touching anything: running each
decoder against real stringConcealing:true / globalConcealing:true
output left it byte-identical (formatting aside). Discovered while
scoping a Lock follow-up (domainLock needs StringConcealing decoded
first to read its regex string; tamperProtection needs GlobalConcealing
working to unwrap checkNative() guards), not something either decoder's
own test suite caught - neither had any tests at all.
Both old implementations targeted an older template shape via a shared
`global.js` helper (`findGlobalFn`) expecting a getGlobal sniffer with a
default-parameter candidate array; the current GetGlobalTemplate takes
zero parameters with a local `var` array instead, so `findGlobalFn`
returned null immediately on every current sample. global-concealing.js
also expected numeric switch keys with a returnName/fallback variant
shape; the current source always uses random string keys with one
uniform `case "key": return globalVar["name"]` shape. string-concealing.js
expected a getter/cache indirection layer with typeof-undefined fake-if
fallbacks that doesn't exist in the current source at all - just a flat
`{ph}_STR_N(start,length) => decode(array.slice(...))` pair per block.
global-concealing.js: pure static rewrite, no evaluation needed - parse
the switch's key->realName map directly (every case has one identical
shape, decoys included) and inline call sites. global.js is now unused
by anything and is deleted - its own matcher didn't match current
source either, so there was nothing left to preserve.
string-concealing.js: rewritten around evaluating each block's decode
function in the existing isolated-vm sandbox rather than hand-porting
its algorithm, since customStringEncodings makes the algorithm genuinely
pluggable (default base91, but arbitrary user code is allowed) - matches
the encoder skill doc's own stated reversal approach. Needed a new
transitive-dependency-closure collector (collectProgramDeps) to pull in
the shared bufferToString/getGlobal-sniffer chain a decode function may
call, without needing to understand that chain's own shape. Two real
bugs caught during verification against actual encoder output, not just
by construction: resolving array/decodeFn bindings from a fixed
Program-level scope instead of the wrapper's own enclosing scope silently
failed to match any block other than the Program root (a real nested-
function block surfaced this); and the dependency collector initially
had no check for "declared inside the node currently being walked",
so it hoisted a decode function's own internal locals out as separate
malformed top-level `var`s (str is not defined).
Tests: test/visitor/jsconfuser/global-concealing/ (one concealed global
among decoys, two call sites to the same global, a mismatched-globalVar
guard case) and test/visitor/jsconfuser/string-concealing/ (one
concealed string, a 3-block case - Program-level unreferenced/dead
encoder plus two nested functions each with their own, confirming both
the per-scope resolution fix and that a fully unused wrapper still gets
cleaned up - and a .substring-instead-of-.slice guard case).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Completes Lock decode coverage - antiDebug, selfDefending, and invokeCountermeasures cleanup already shipped; these three were deferred on the theory their literals were exposed to later-pass mangling. That turned out to be unfounded (lock.ts's own path.skip() persists across the rest of the pipeline via Babel's path cache), and the real blocker was that string-concealing.js/global-concealing.js were non-functional until their earlier rewrite. All three are straightforward structural matches once that dependency is met. Also fixes two real bugs surfaced while testing against combined real-encoder output: - selfDefending/invokeCountermeasures matching moved from enter to exit: a dateLock/domainLock guard can be recursively inserted into these templates' own nested blocks by the encoder's Block:exit visitor, and matching at enter saw the guard-polluted shape and silently failed to match. - Program:exit cleanup order: tamperProtection's own countermeasures call sites must be removed before invokeCountermeasures' own cleanup runs, or invokeCountermeasures is left behind permanently undeletable despite having zero real remaining references. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
deStackFuncLen's checkFuncLen matcher assumed the SetFunctionLengthTemplate's
{value, configurable} object keys were plain Identifiers, but the template
itself hard-codes quoted keys ({"value": ..., "configurable": ...}) - the same
computed bracket-string form js-confuser's Preparation pass normalizes every
object key to. The visitor was registered for Identifier nodes only, so it
never even fired on real (non-minified) output. Also fixed a second bug in the
same call site: the second call argument (the target length) is omitted
entirely when it equals the template's own `length = 1` default, which crashed
on `.value` of undefined.
Found while verifying RGF's preserveFunctionLength interaction - a third,
deeper gap (processStackParam assumes VariableMasking's rest-param shape,
which preserveFunctionLength doesn't require) remains open and undocumented
here intentionally, per discussion with the user to keep this fix scoped.
Co-Authored-By: Claude <noreply@anthropic.com>
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The comment still said dateLock/domainLock/tamperProtection weren't decoded yet, but lock.js has covered all six Lock sub-features since the previous session. Noticed while wiring in the new RGF decode step right below it. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
RGF (order 4) moves an eligible function's params/body into a synthetic sub-program, recursively obfuscates it with (almost) the entire pipeline, serializes it to a string, and executes it via eval at runtime, storing the result in a shared Program-level array. Reversing it means recursively running this same decode pipeline on the extracted string (a controlled circular import of this file's own plugin/jsconfuser.js default export, invoked only at traversal time), then splicing the recovered params/body back onto the original function. Matches structurally throughout (no identifier-name assumptions): the eval-wrapper function, the shared array feeding it, and each transformed function's shrunken call site. Handles both the computed bracket-string and plain dot forms of member access, since js-confuser's Preparation pass unconditionally normalizes every non-computed member access - including on RGF's own recursively obfuscated sub-program - to bracket-string form, and only Minify (not always enabled) converts it back. Wired in right after Lock and before Flatten: none of RGF's own inserted scaffolding is path.skip()-protected on the encode side, so like Lock and Flatten its output is exposed to nearly every later transform, and it needs the earlier calculate-constant-exp passes to have already folded the call site's array index into a plain NumericLiteral. Verified against real encoder output (not just constructed fixtures): a Flatten-eligible function that RGF also captures is fully restored by the recursive decode composing with Flatten's own decode, confirmed via a new whole-pipeline fixture (test/jsconfuser/) - the first of that kind, closing part of a previously-tracked gap in combined-transform test coverage. A nested-function case confirms only the RGF-eligible outer function is affected. Multiple independent RGF'd functions sharing one array are each correctly correlated by index. A preset:high + pack sample was checked too but not turned into a fixture - the leftover cruft it produces is dominated by the (separately tracked, still-unimplemented) ControlFlowFlattening reversal gap, not informative for verifying this transform specifically. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
resolveXorHelper required a candidate call site's start and length arguments to be NumericLiterals. That reads like a free extra check and is not: the function runs before undoLiteralEntanglementInGraph, and by then those two positions can hold plain arithmetic instead. On the 96-sample corpus the gate rejected the only call sites present in 6 of 21 search roots. The consequence was out of all proportion to the check. A search root that finds no helper decodes with xorFnName null, which leaves every entangled key unresolved; flattenScopeMembersInGraph then reads the chains it can and silently leaves the rest, so one scope slot ends up addressed two ways. The body is relocated out of the function that binds the scope object, and the surviving reference either dangles or binds to an unrelated same-named identifier in the destination. Identification now rests on the binding and the string blob, which are the discriminators that actually distinguish this helper, rather than on a spelling a later stage rewrites - the same correction this file has already had to make for name-keyed lookups. Corpus: 92/96 -> 94/96 correct, 685897B -> 671213B, 471 -> 433 array reads. The two samples fixed are opaque-predicates.1 and moved-declarations.2, the latter having previously produced output that never terminated. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
cleanupOrphanedScopeAnchors only considered an anchor whose holder resolves
to a binding, on the reasoning that an unbound holder might be a real global
whose property set is observable outside this file. That left the worst case
untouched. The CFF scope object is often a parameter of the main function,
and when the decode relocates that function's body the `scope.prop = {}`
statement travels with it while the parameter does not - so the surviving
statement is a guaranteed ReferenceError, which is exactly the shape the
guard was declining to remove.
An unbound member assignment cannot be doing useful work, so removing it
restores the program rather than changing it. Real globals are still
excluded, by name against an allowlist - the only test available, since the
binding that would have answered structurally is precisely what is missing.
Corpus: 94/96 -> 96/96 correct, 671213B -> 671053B. All three
decoder-introduced dangling references unbound.mjs reported are gone,
including calculator.1's, which the runtime check never caught because its
site never executes.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…tries
Two changes that have to land together, because the first without the second
is much worse than neither.
parseCreateFunction read its helper positionally - exactly four statements in
fixed order - and required the helper itself to be a FunctionDeclaration. On
a `high` sample MovedDeclarations has packed both: the declaration arrives as
a bare `createFn = function (){…}` assignment, and the two `var`s inside it
as a merged declarator plus assignments, sometimes with a dead trailing
`return;`. Reading the slots by role instead - the returned identifier, the
`if` test - takes template matching from 0 to 47 of 49 applications.
That alone decoded 47 wrong programs, 96/96 to 49/96. parseFnsEntry's
no-parameter fallback is reached by *any* unrecognised first statement, so
for the two spellings it does not know - MovedDeclarations' bare
`[a, b] = payload`, and a masked `[stk["a"], stk["b"]] = payload` - it
returned a zero-parameter function whose body still read the payload variable
that cleanup then deletes. parseCreateFunction's strictness had been gating
that shut, which is why the doc's claim that it "affects nothing" was wrong:
it was load-bearing as a gate. It now declines when the body still reads the
payload, which takes the whole dispatcher down with it.
Entry parsing also moves to the end of the matcher. It is the only step that
mutates - unmasking via processStackParam(entry, 0) - and that 0 is not read
from the code but is an invariant of Dispatcher's own template, so it is only
sound once the template is fully confirmed rather than partly.
Corpus unchanged at 96/96 and +8B of 671KB: no dispatcher decodes yet, since
those two unpack spellings are declined rather than understood. That is the
remaining work, and it is where the bytes are.
Three comments named MovedDeclarations as the cause of shapes it cannot
produce. It bails on any non-identifier declarator, so it cannot rewrite
`var [a, b] = payload`; and its FunctionDeclaration handling requires a
direct child of the packed function - createFunction sits inside the
non-call branch's block - and emits a conditional `if(!F){F=…}` prologue
rather than the bare assignment actually observed.
The shapes themselves are unchanged and still matched; what goes is the
causal claim. Where the source is known it is named, and where it is not the
comment now says the spelling is accepted on the strength of being observed
rather than attributed - which is the honest state and stops the next reader
building on a guess.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The unpack line of a masked function whose original parameter list contained a destructuring pattern arrives as an ArrayPattern of ArrayPatterns, and `processArrayPatternAssign` declined the whole assignment on the first nested element. For a Dispatcher `fns` entry that decline is terminal: nothing later supplies the arity needed to unmask it, so the entry stayed masked and its whole application stayed undecoded. The two visitors change together because separating them regresses the corpus. Promoting nested slots exposes entries whose stack is only *reduced*, not removed - a slot touched by an `UpdateExpression` is marked invalid by design and no arity fact can rescue it - while reconstruction rebuilds the entry with the pattern's identifiers as its parameters and so drops the rest param the stack lived in. Without the dispatcher-side guard those bodies reconstruct reading a parameter that is no longer there. Rewriting the promoted slots' other references in the same call is the third part and the one that is not visible from reading: `cache` is rebuilt by `initStackCache` on every `tryStackReplace` call, so a registration made here never survives to the next iteration, and by then the assignment that would re-register it is gone. References the traversal already walked past - notably inside a nested function declared above the unpack line - would otherwise be left addressing a stack nothing populates. 96-sample high-preset corpus, same-input A/B: 96/96 runtime-correct either way, 671061B -> 660422B, 433 -> 267 residual array reads. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…id slots A slot `checkStackInvalid` marks invalid is unfoldable, not unresolvable, and treating the two as the same thing left every such entry half-unmasked. The marking is scoped to value substitution - nothing can be substituted into `stk[3]++` - while `unmaskStack` never reads it, promoting each remaining slot to a real local that `_local++` satisfies. Nothing was wrong upstream; the routine that resolves these was simply unreachable, since `deVariableMasking` gates it on a truncation statement and Dispatcher marks its entries PREDICTABLE so one is never emitted. Driving it from `parseFnsEntry` is the same move the file already makes with `processStackParam`, and for the same reason: the entry is anonymous, zero-arity and never called, so no arity is inferable from it, while this template knows structurally that its entries have zero params. That is the exact length `unmaskStack` requires - it refuses inferred counts, not exact ones. The surviving-stack guard stays as a genuine fail-safe: `unmaskStack` has its own declines (`arguments` use, an observable stack), and a slot surviving one of those is still unreconstructible. 96-sample corpus: 96/96 either way, 660422B -> 660038B, 267 -> 201 residual array reads, guard declines 6 -> 0. Reconstruction is unchanged at 9 dispatchers / 34 entries - the six moved to the reconstructibility check ahead of it, which reads body[0] and now finds the locals declaration unmaskStack unshifts. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
`fnsEntryIsReconstructible` and the entry match below it both read `body[0]` and accepted only the declaration spelling, so every application that had been through VariableMasking or MovedDeclarations declined - 70 entries corpus-wide, not one of them at index 0 and not one still carrying stack slots. Neither degree of freedom was optional. The assignment spelling is the encoder's: VariableMasking's `replaceDefiningIdentifierToMemberExpression` replaces the whole declaration with an expression statement. The displacement is partly ours, from `unmaskStack`'s unshifted locals. What may be skipped over reuses `unmaskDestructuredRest`'s condition rather than a looser one. Two preconditions had been satisfied only by accident, because these entries declined before anything looked at them - both surfaced as wrong output, not as declines, the moment the unpack line became locatable. An entry can capture the dispatcher's own `fnLengths` parameter through a surviving scope anchor, which reconstruction would leave unbound; that fails closed. And an entry can already carry a parameter list restored by our own `unmaskDestructuredRest`, which reconstruction replaced outright; since the template only ever calls `fns[name]()`, those names are always undefined on entry and are re-bound as the locals they are. Corpus, same frozen input: 96/96 runtime-correct throughout, 660038B -> 628718B, 201 residual array reads unchanged, unbound references back to the encoder's own three. Entry declines 70 -> 13. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
… binding
safeDeleteNode guarded its entry lookup but not the lookup it repeats after
binding.scope.crawl(). A caller that resolved its target before a pass rewrote
the enclosing function body holds a binding registered against the old body; the
crawl rebuilds from the new one, and when the rewrite dropped the name the
refreshed lookup returns undefined. Reading .references off it took the whole
pass down, which is why the call-harness collapse works around it by collecting
deletion paths up front.
The mechanism recorded in that workaround's comment was wrong, and it named a
non-fix: this is not Babel's child-path cache going stale, so a path-based
get('body').replaceWith() behaves identically and fixes nothing. Measured with
sandbox-tests/mask/repro-safedelete.mjs across both rewrite forms and all three
crawl orderings.
Declining matches the answer the entry guard already gives for a name that binds
nowhere - the same state, discovered one step later. Corpus output is
byte-identical (96/96, 628718B), since nothing in the pipeline reaches this path
today; it is #10/#3/#4 that will.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The scope object's own `scope[k] = {}` sub-scope initializers were left standing.
matchScopeMemberChain deliberately requires two levels, so flattenScopeMembersInGraph
never rewrites a one-level initializer, and the scope object's binding does not
survive this decode - its parameter slot is replaced wholesale.
That is why cleanupOrphanedScopeAnchors could not remove them. By the time it runs,
`scope` in a leftover `scope.k = {}` resolves up the scope chain to an unrelated
same-named entity, and the guard weighs that entity's references instead. Measured
corpus-wide before this change: 266 anchors seen, 31 declined, of which 30 on
`escapes` and 1 on `opaqueKey` - and zero on a genuine read of the anchor's own key.
On one sample the resolved holder was a live UTF-8 encoding helper that has nothing
to do with the anchor.
Dropping them where they are produced needs no name resolution at all: scopeName is
already resolved from the interpreter's own shape, so there is nothing to
misresolve. Removal is a filter on the array foldBranchesInGraph emits, not a tree
mutation, so no scope work is involved. Fails closed as a whole on a bare scope
reference or an unreadable key, since either makes every key's liveness unknown
rather than one key's.
Corpus, same frozen input: 96/96 throughout, 628718B -> 621768B, 201 -> 191 array
reads. Anchors reaching the Program cleanup 266 -> 64 with declines 31 -> 1;
survivors in decoded output 16 -> 9. Dispatcher fns-entry declines 13 -> 12, the
capture guard 2 -> 1 - the one that closed was a dissolved scope object, the one
left genuinely addresses a dispatcher parameter. unbound.mjs A/B'd on the three
affected samples: identical, nothing introduced.
The two fixture updates are pure removals of the dead initializers, runtime-verified
against their .src.js.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…ot by name dropDeadScopeAnchorsInGraph inspected every Identifier sharing the scope object's name, so three unrelated things read as the holder being used as a bare value - which is a bail, failing that whole graph's anchors closed: var <name> a declarator id function <name>(...) a function's own name function (a, <name>, c) a nested function's parameter None is a reference. Breadcrumbing the bails made this unmistakable: the "live keys" they reported were indexOf, length, key, val - Array and String members, not the random slot keys a scope object has. RenameVariables hands out short names that collide freely across scopes, so this is the common case, not an edge one. Two filters, in order: the identifier must be a reference at all, and it must resolve to the holder's own binding, taken from an anchor site rather than from the name. Name matching now stands in only when the holder has no resolvable binding, and then only to fail closed - an unrelated reference can add a live key or force a bail, never authorise a drop. Corpus, same frozen input: 96/96 throughout, 621768B -> 617081B, mean ratio x28 -> x27, 191 array reads unchanged. Anchors dropped at the graph 205 -> 271, with bails 28 -> 0 and no anchor kept for a live key. Surviving anchors in decoded output 9 -> 0. Dispatcher fns-entry declines 12 -> 11 with the capture guard now firing nowhere, and 98 entries reconstructed: the one decline previously written up as a genuine dispatcher-parameter capture was the same misresolution one level down, and binding identity dissolves it too. unbound.mjs unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…s a default
`unmaskDestructuredRest` required every element of the folded `[a, b] = rest`
pattern to be a plain identifier, so a default (`[a, b, c, d = {}] = rest`)
declined the whole function. That is not an edge case: it is every dispatcher
this pipeline's own control-flow-graph reconstruction hands on, since the
Dispatcher template's fourth parameter carries an object default. 29 of them
corpus-wide, one cause, measured at the bail.
A destructuring default and a parameter default fire on the same condition, so
the two spellings mean the same thing. What does not simply move is the default
*expression*: a non-simple parameter list gets its own scope that cannot see the
body's `var`s, and a body with a directive cannot have one at all. Both are
refused rather than relocated.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The graph carries a function's implicit "and then it ends" as an explicit `return` node, so every reconstructed body ended in a `return;` / `return undefined;` doing exactly what falling off the end already does. The call-harness collapse then adds one of its own, correctly where the spliced statements would fall through to something and pointlessly where they are the tail of a function body. Dead is not harmless here: it displaces the real last statement, which is what a matcher reading a template's roles from the end of a body has to see. That is an Upstream Effect of ours, so it is fixed at the pass that emits it rather than tolerated in each matcher it defeats. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
… callee ControlFlowFlattening re-spells a variable it moves into its scope object as `scope["a"]["b"]`, and has to re-guard a direct call of one as `(1, scope["a"]["b"])()` so the member access does not supply a `this` the original call never had (controlFlowFlattening.ts, "Preserve proper 'this' context when directly calling functions"). `flattenScopeMembersInGraph` replaces that chain with a plain identifier, for which `(1, f)()` and `f()` are the same call - so the guard's whole reason is gone and it is left as noise on every such call site. Worse than noise for a consumer: it puts a SequenceExpression where the reference should be, which is what walked the dispatcher matcher past its own call sites. Unwrapped here, at the pass that strands it. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
`deDispatcherInit` scanned a block for a `FunctionDeclaration`, and a
`FunctionDeclaration` is one spelling rather than the spelling: this pipeline's
own control-flow-graph reconstruction emits `X = function (...) {...}` beside a
hoisted `var X`, and the dispatcher is as flattenable as anything else
(ControlFlowFlattening is encoder Order 24, Dispatcher Order 6). 29 dispatchers
corpus-wide never reached the matcher at all.
The name now comes from the holder's binding rather than from the assignment's
left-hand text: `resolveBindingFunction` is asked what the binding defines and
the candidate is taken only when that is this very function, so a binding written
more than once declines - reconstruction deletes the holder, and a second write
would mean deleting someone else's definition with it.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…s shape
`isDummyPredicateFn` - duplicated by design in opaque-predicates.js and
dead-code.js - required a niladic, empty top-level `FunctionDeclaration`. That
is the encoder's spelling, and it is a proxy this decoder's own passes break
twice over: control-flow-graph.js reconstructs the anchor as `var X;` +
`X = function (...r) {}`, so `binding.path` is an init-less declarator and the
arity is a rest parameter we added. 69 anchors carrying 192 guard sites were
walked past corpus-wide.
The soundness condition was never the shape. `"p" in X` reads false exactly
while nothing adds `p` to `X`, so the test is now that every reference to the
binding is an `in` test's own right operand - never a member base, a callee, or
a value handed somewhere that could write to it. Measured first: all 192 sites'
anchors already satisfy it. The arity is not consulted at all, since an empty
body's own-property set does not depend on its parameter list.
Nothing else was needed for the dead helpers behind those guards: both matchers
already resolved *those* through `resolveBindingFunction` and swept them at
`Program: exit`. Only the anchor was blind.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
`findUnpackLine` could only skip initializer-less declarations on its way to the
entry's unpack line, so a hoisted `FunctionDeclaration` or an `X = function (){}`
assignment above it declined the entry - and with it the whole dispatcher, the
match being all-or-nothing. 16 entries across 14 dispatchers corpus-wide.
The condition was guarding the right hazard with the wrong test. What breaks the
rewrite is a *read* of a promoted name reaching the program ahead of the unpack
line: it sees `undefined` today and would see the argument once the line is
dropped and the names become parameters. Declaring a function does not read
anything, and building a closure evaluates neither its body nor its parameter
defaults - the reads inside happen when it is called, which nothing above the
line does. An `if` can run arbitrary code and is still refused; one entry in the
corpus declines on exactly that, correctly.
Inertness is only half of it. A skipped statement that *binds or writes* a
promoted name would survive the rewrite while the unpack line does not, becoming
the last word on that name instead of being overwritten by the payload, so those
are refused too - excluding declarations, which the assignment form requires and
`stripPromotedDeclarators` removes.
Dispatchers matched 64 -> 77 of 78, entries reconstructed 146 -> 161, corpus
96/96 at 390809B -> 374771B, `unbound.mjs` still zero.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…gh its binding
MovedDeclarations (encoder Order 25) can pack a declaration onto the enclosing
function's parameter list. The binding then reads `kind: 'param'` with
`binding.path` pointing at the parameter's own Identifier, and the real
declaration demoted to a constant violation - so three separate checks here were
asking the wrong node:
- `resolveWrapperBinding` compared `binding.path === fnPath` and declined a
wrapper that was otherwise entirely matchable;
- the decode-fn check read `isFunctionDeclaration()` off the parameter and saw
false for something that is one;
- `collectProgramDeps` filtered the same way, so a transitive dependency in
that spelling would have been dropped from the bundle silently, and
`addDependency` would have emitted a bare parameter name where a definition
belongs.
All four now go through one `declarationPath` helper over
`utility/binding-def.js`, which is what reads a binding's real definition. Only
the first was reachable on the corpus; the other three are the same defect found
by grepping for it rather than by waiting for it.
Corpus 96/96 at 374771B -> 340069B, residual `arr[n]` reads 25 -> 0, wrappers
1949 matched / 1949 replaced, `unbound.mjs` zero. The dispatcher's last entry
decline closes with it: the guard it hit was DeadCode's, unrecognisable only
because this pass had left its key string concealed.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
`deDispatcher` is scheduled immediately before this matcher and after the CFF decode, so a CFF-flattened dispatcher is already back in template shape by the time it runs - and it reverses every call-site spelling `createDispatcherCall` emits, including the two this collapse was written to grow into. The narrow collapse therefore matched nothing: zero call sites across the 96-sample `high` corpus, and zero on the fixture written for it, whose output it turns out not to have produced. Removing it leaves the corpus byte-identical. What that slot still owes is the second `cleanupOrphanedCffHelpers` sweep, which is genuinely load-bearing: the dispatcher template is routinely the last holder of a CFF runtime helper's reference, so the sweep inside the CFF decode cannot reach those helpers and only this one can. The pass is renamed for what it does. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The Program-level sweep looked only for a FunctionDeclaration, which is how dispatcher.ts prepends the helper - but ControlFlowFlattening (Order 24, well after Dispatcher's Order 6) can outline that declaration into its own switch/case table like any other, and an outlined function reads back as a plain assignment against a hoisted declarator. On a `high` corpus that is the only spelling the helper survives in, so the sweep declined on every one of them. No output moves on its own: the reconstruction still hands these functions a rest parameter, which the shape check refuses. The companion commit removes that. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
An outlined nested function was given the shared dispatcher's rest parameter
unconditionally, so a function the original source declared with no parameters
decoded to `function (...arg) {}`. The parameter is invented: a rest parameter
contributes nothing to `fn.length` and a body that never mentions the name
cannot observe it, so emitting one only re-spells a niladic function as a
variadic one - and every later matcher reading for the niladic shape then
declines. Fixed at the pass that emits it rather than tolerated per consumer,
the same as dropTrailingDeadReturn beside it.
The visible consequence is dispatcher.js's d_fnLength sweep, whose empty-no-op
shape check requires zero parameters: the helper now reaches it in the shape the
encoder emitted, and 30 of the 31 orphaned stubs across the 96-sample `high`
corpus go with it. The survivor is a different animal - a real function whose
body only becomes empty two passes later - and is left alone rather than absorbed
by widening that matcher.
Corpus: 96/96 runtime-correct, 340069B -> 338767B, zero unbound references.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…meter use statementsMentionName counted any Identifier spelling the name, so a wrapper containing another decoded function was always reported as using its own rest parameter. That is not an edge case: every wrapper in one recursion is handed the same ctx.argName, so a nested wrapper binds the identical name by construction, and the scan therefore answered "used" for exactly the case the caller needs decided - any function with a decoded function inside it. It now stops at a function that rebinds the name, which is reading someone else's variable. Only parameter shadowing is modelled; a nested `var name` still counts as a use, which keeps a redundant parameter rather than dropping a read one. Closes the last empty-function orphan in the corpus. It was DeadCode-injected fake code whose guard dead-code#2 correctly removes, orphaning the closure inside it and emptying the body - and the invented rest parameter was the only thing keeping the Program-level empty-helper sweep off it. Corpus: 96/96 runtime-correct, 338767B -> 338724B, S3 orphans 1 -> 0 across all 96 samples, zero unbound references. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
Removing a matched entry harness left two shapes standing in every reconstructed body it was MovedDeclarations-hoisted in. Both are dead only because the harness went, so both belong at the removal site rather than in a matcher: the bare `var didReturn, result;` declarators Mechanism 1 pushes onto the enclosing block's leading `var` statement, and the `didReturnVar = true` writes Stage 2 wrapped around returns nested inside statements CFF copied through opaquely, which `parseReturnValue` never sees. Gated on the flag being unread rather than on which decode path produced it - that is the condition making the writes unobservable, and it declines on an inline-flattened function's still-live flag without knowing that path exists. Corpus: 1200 wrapped returns and 321 dead declarators removed across 96 samples, 338724B -> 315048B, mean ratio x15 -> x14, 96/96 still runtime-correct with zero unbound references. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The pass matched nothing on a `high` sample: its switch function is sealed inside the ControlFlowFlattening interpreter where it runs, so the population there is zero. Running it again straight after the CFF decode is not enough either - what that decode hands back fails every gate the matcher has at once (rest param, string-decode wrappers standing ahead of the switch, a discriminant that is not the param, case tests still spelled as wrapper calls). Measured per stage, the candidate shape first exists after StringConcealing's second visit and the full match only after the fold following it, so the second visit is scheduled there. Two matcher fixes the newly-reachable path needed. The sniffer is identifiable only as the callee of globalVar's initializer, and MovedDeclarations splits that declaration, so reading node.init alone left it as a zero-reference orphan. And one reference can be registered twice - 50 referencePaths over 49 distinct nodes on a real sample - where replacing the second occurrence resynced to a null key and threw inside Babel's validator rather than declining. Corpus: 315048B -> 165901B, mean ratio x14 -> x7, zero residual GlobalConcealing switch functions across all 96, 96/96 runtime-correct, zero unbound references. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The CFF decode hands back the masked stack as a bare declaration plus a separate copy statement, so removing the copy is what makes the declaration dead - and the pass that creates the deadness is the one that should clear it, rather than every downstream matcher learning to look past a leading dead var. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…ding Keying the visitor on FunctionDeclaration meant it never fired on a high sample, where the CFF decode hands the switch function back as a hoisted var plus an assignment - not a declined match but no match attempted. Driving it from what the binding defines is what the sibling visitors already do, and it is fail-closed on a re-assigned holder, whose call sites are not all reading the function the match was built from. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…eclarator
The scan read two statement shapes and the doc described it as binding-driven,
which it was not: var d = function (){} - what the CFF decode leaves when
nothing afterwards splits it - was walked past, and the matcher was never
called at all rather than declining. Byte-identical across the corpus today,
so this is reach rather than behaviour.
Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…length A two-statement body test read the MovedDeclarations-split flat object as a different pattern and declined before any real matching, on roughly half of high runs. Reading the return backwards and resolving the object through its binding accepts both spellings, and fails closed on a second write - where the object handed to flatFn would not be the one read here. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
The CFF decode rebuilds a swallowed function as a declarator or an assignment, which a binding.path.isFunctionDeclaration() gate refused - 12 of 12 runs under flatten+controlFlowFlattening, with the wrapper above already matched. Reach rather than behaviour: byte-identical across the corpus, because the same population then declines one step later in extractGShape. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…ogue control-flow-graph.js emits [flatObject, [a, b]] = rest for a function whose original parameter list held a pattern, and readFoldedElement accepted only identifiers - so every such function stayed rest-masked and each consumer met a shape it could not read. The element moves into the parameter list unchanged: the same destructuring, one step earlier. Closes the half of checkpoint 6.3(b) that no binding could reach. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
…ling Minify (encoder Order 28, later than GlobalConcealing's Order 12) rewrites globalVar["Math"] to globalVar.Math wherever the key is a valid identifier. The matcher is all-or-nothing, so requiring the computed form let a single minified case among forty leave an entire GlobalConcealing layer undecoded - 4246B to 1308B on the worst affected sample. Signed-off-by: echo094 <20028238+echo094@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
close #112
List of transformations (in version 2.0):