bug: the JS/TS plugin never captures CommonJS require() — express's entire module graph is invisible (410 calls, 0 imports) #56
Labels
No labels
code-review
correctness
dos
performance
security
severity/high
severity/low
severity/medium
tech-debt
Kind/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
h-dv/code-index#56
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Found by #45's baseline, which recorded
js-express imports=0as an oddity. Investigated for #46. Measured, not inferred.The defect
crates/plugins/src/typescript.rs(the single plugin for.ts/.tsx/.js/.jsx/.mjs/.cjs) emits imports only from theimport_statementnode — see the match arm attypescript.rs:286and the flattening at:624-716. A CommonJSrequire()is acall_expression, so it is never seen. The only two textual matches for "require" in the plugin arerequired_parameter(unrelated).Reproduction
Scale of the impact
require(require(callsexpress is 100% CommonJS, so its entire module graph is invisible to the resolver. Every import-based resolution path is starved: tier 1b's file-key and package-tail reachability arms, tier 3's import boost, and tier 1R's origin gate all join
imports.Why this matters for #46
#46 asks how much of the 3.4× cross-language resolution spread is a denominator artifact vs a real plugin gap. For js-express the answer is now measurable:
So the internal-eligible rate is 4154/(4154+7732) = 35%, not the headline 21%. A large share of those 7 732 are plausibly reachable if the module graph existed.
Note this also exonerates ts-zod: it is essentially all-ESM (1 006 import lines, 2 requires), so its 13.2% is not this gap and remains best explained as denominator composition (type-level refs into TS lib built-ins).
Why I did not just fix it
This is a recall change, and recall changes are where this project has repeatedly drawn blood — I037's first fix was wrong and shipped anyway. Feeding 410 new import edges into three resolution tiers can mint phantoms, so it needs the full treatment rather than a drive-by:
require("...")(and likelyimport(...)) as imports, with the same shape ESM produces;modulenormalisation so existing joins (i.module = k.key, theIMPORT_SEGMENTSGLOBs) work unchanged;const { a, b } = require(...)) — the per-binding split in #31 item 2 is adjacent;precision_gate(phantom == 0) and the corpus mutation guard;tests/corpus/baseline.jsonwith the reason (importsandresolvedwill both move for js-express) — the ratchet will fail loudly until then, which is the intended behaviour;Acceptance
require()and dynamicimport()captured as imports in.js/.cjs/.mjs(and.tswhere used)phantom_count == 0across all 7 languages; corpus mutation guard still 0 rebinds / 7 056 sitesresolvedmeasurably improves; baseline re-blessed with a written reasonSeverity: medium
No phantom, no crash — it is a silent recall ceiling for an entire module system, and CommonJS is still most of the Node ecosystem. It has been invisible because no fixture used
require().Part 1 shipped (
4a98948+9bf792f). Part 2 BLOCKED with evidence — and my original estimate here was wrong.What shipped: the CommonJS export surface (precision)
collect_cjs_exportsreadsmodule.exports/exports.xand marks module-level symbolsExportedorFileinstead ofUnknown. A file with no detectable exports keeps the old conservativeUnknown— global scripts and unmodelled re-export idioms must not lose real resolutions (pinned bybare_cjs_without_exports_stays_unknown).Measured on express: resolved 4154 → 4152, gained 0, lost 2. Both removed bindings are verified phantoms — a local
idinexamples/route-middlewareandtest/app.router.jsbinding toexamples/mvc/controllers/user/index.js's module-privateid. Pure precision, no recall cost.m0025is the extraction re-heal (stat+hash invalidated, m0021/m0022 pattern), because visibility is recorded at parse time and existing DBs keep their staleunknownrows otherwise.One subtlety that produced a wrong result before I caught it: the export set must contain only names another file can refer to a symbol by, not every value that escapes. express writes
exports.request = req; collecting the right-hand identifier made the privatereqExportedagain and resurrected the very phantom class this removes. Keys only.Part 2 — require() capture — implemented, measured, REVERTED
It worked, in every binding form, and correctly declined computed/interpolated specifiers:
express: imports 0 → 388. And then:
resolved +6 / −2, and all six gained are PHANTOMS.
lib/application.js:221binds a localfnintotest/utils.js#fn— a production file into a test file.Root cause is upstream of the capture:
require("./utils")fromlib/stem-matches bothlib/utils.jsandtest/utils.js, because tier 1b's file-key arm compares stems and ignores relative-path semantics. Landing correct import rows simply widens reachability through that existing hole. Net +4 phantoms against the guarantee that is this product's core claim, so it is not shippable.The code is reverted rather than left dead. It is straightforward to restore once relative specifiers are path-resolved.
Correcting my own numbers in this issue
Two things I asserted above are wrong and should not be relied on:
util.method()— a member call through a module object — which needs receiver typing, not an import edge. A minimal 2-file probe (const util = require("./lib/util"); util.uniqueHelperFn(1)) still does not resolve with imports captured. The realistic upside is single digits, not thousands.visibility_forwas handed the declaration node, which has nonamefield, so everyvarlooked up as""and got File-scoped regardless of export status). With correct semantics those phantoms remain. Only 2 are removed.Acceptance status
require()/ dynamicimport()captured — implemented, reverted, blocked on relative-specifier resolutionphantom_count == 0across all 7 languages; mutation guard 0 rebinds / 7 056 sitesresolvedimproves — it decreases by 2, and that is the correct direction (phantom removal)resolved 4154 -> 4152 (-2))Follow-up to file
Tier 1b's file-key arm should path-resolve relative specifiers.
./utilsfromlib/must meanlib/utils.js, not any file whose stem isutils. That unblocks part 2 and is very likely removing phantoms in the other languages too.require("./utils")from lib/ reaches test/utils.js #57Fixed and released in v0.9.0 (schema v25, migration
m0025_commonjs_module_graph— an extraction re-heal, so it re-parses on first run).Both halves shipped: the export-surface analysis (part 1) and
require()import capture (part 2). express went from 410require()calls and 0 imports to a real module graph.Part 2 has some history worth recording, since it landed twice:
require("./utils")fromlib/could reachtest/utils.js). #57 was fixed first, then part 2 re-landed with zero phantoms.visibility_forwas handed the declaration node, which has nonamefield, so everyvarlooked up as""— this also produced a false "25 phantoms eliminated" claim that had to be retracted. Andexports.request = reqresurrected a phantom by collecting the right-hand identifier, which marked privatereqas Exported;collect_cjs_exportsnow collects keys only.Verified live: the released binary indexes
require('./util')asmodule=./util, alias=helperwith 0 phantoms.require("./utils")from lib/ reaches test/utils.js #57