Skip to content

Code Analysis Node Conditions

Tu Shaokun edited this page Nov 6, 2026 · 3 revisions

Node package conditions and source-resolution boundaries

Validation date: 2026-11-06. Base: labs/code-analysis@bf0a29e7. Candidate: f160d1deb, submitted as Draft #1219. This is a validation and review record; the candidate is not merged. Validation index

What was wrong

With a package map that gives import and require different files, Labs can mark an ESM import as resolved to the CommonJS target. Dynamic import() inside a CommonJS file also selects the wrong branch. This was first reproduced in a constructed six-file probe during source inspection. It is a wrong-target problem even when the resolved count does not change.

The adapter called TypeScript's resolver without its usage mode. In the installed TypeScript 5.9.3 implementation, NodeNext resolution without a mode selects require conditions. The candidate gets the mode from program.getModeForUsageLocation for each literal reference. That API accounts for the file/package format, compiler options, emitted syntax, and explicit type-only resolution-mode attributes. The existing snapshot host, parser and resolver remain in use.

Independent checks and fixed inputs

Input Scope and oracle Labs → candidate
Authored conditional-import probe 6 files, 6 references; direct Node execution and import.meta.resolve / require.resolve Four wrong targets corrected; total resolved count stays 6
Node package-import fixture at aa4c7758 Five unchanged upstream files plus two explicitly authored literal callers; 6 JS files, 6 references Three wrong targets corrected. Four literal references agree with Node; the upstream importer's two computed references remain opaque
dependency-cruiser at e6517db0 src/**/*.mjs, bin/**/*.mjs, test/api.spec.mjs, test/report/mermaid/mermaid.spec.mjs; 198 files, 550 references All import records unchanged; six API self-references remain unresolved as described below
kleur 4.1.5 at fa345448 Default scan; 10 selected files, 23 references All import records unchanged

All four scopes have zero parse errors. File records and function measurements are unchanged. The normal CLI and the unpacked archive produce byte-identical raw facts for each input. The two real projects are compatibility controls and sources of remaining gaps; this experiment does not claim that they experienced the constructed wrong-branch incident. Node's upstream fixture was adapted with authored callers, not presented as a full Node repository scan.

Three added regressions fail on the exact Labs base and pass with the fix. They cover JavaScript import/export/require/dynamic import, TypeScript emission and explicit type-only mode overrides, and unavailable or directly null-mapped imports. The module suite passed 231/231; after selecting adapter revision 5, all 52 affected semantic, identity, comparability and revision checks passed. Revisions 2 and 4 already belong to separate TS candidates #1213 and #1214. Combining candidates will require its own distinct extraction identity.

The archive was built twice with official Node 22.23.2, with SHA256 650b2cfd38d34bd4f716cb8be53917e42882531e887fb161dd94bedb5644a95f. CLI/oracle checks used Node 22.23.3 and TypeScript 5.9.3. Local Python-dependent module tests used Python 3.13.16. CI status is recorded on the PR; local checks do not establish remote acceptance.

What the other analyzers teach us

dependency-cruiser uses enhanced-resolve, with caller-configurable conditions. Its generated configuration includes import, require, node, default and types together. Its lower-level default also disables export-map processing for compatibility unless configuration enables it. These are explicit policy choices; a run's configuration must be known before its result can be used as a comparison.

For Archify's present per-import evidence, the useful design is to preserve the loading context of each reference and reuse the existing compiler API. Adding another resolver would create a second policy to maintain. Node execution or its resolver provides a separate oracle for the particular runtime case; TypeScript's source/declaration resolver answers a different question. A larger resolved count alone cannot establish accuracy.

JavaScript implementation follow-up

The remaining declaration-shadow cases now have a separate candidate: 06dd7340c, Draft #1220, based on #1219. The two PRs form native Stack #1221. Neither is merged.

The follow-up performs an implementation lookup only when a non-type-only reference from .js, .mjs or .cjs selected a declaration and the reference is a package import or uses ESM resolution. It keeps TypeScript callers' type/source resolution and leaves relative CommonJS behavior to #1214. Missing or excluded implementations remain unresolved even when declarations are included.

It reuses TypeScript's noDtsResolution option with the captured host and prevents TS substitutions during this JS-only fallback. The language service uses the same internal option for implementation lookup, but it is not part of the public CompilerOptions declaration. That dependency is an explicit maintenance cost: the locked version and implementation-selection regressions must be checked on parser upgrades. There is no second resolver dependency. Both target-boundary checks remain because the primary and fallback lookups can return different files.

Pinned source and scope Files / references Corrected references Resolved count before → after
dependency-cruiser e6517db0, source/API-test scope above 198 / 550 6 366 → 372
Same project, also selecting types/**/*.d.mts 225 / 607 Same 6 425 → 425
chalk 6.0.1 72110019, defaults 16 / 40 13 1 → 14
Same chalk source, also selecting .d.ts 20 / 46 Same 13 19 → 19

All 19 distinct references agree with Node's independent resolver in both declaration-selection scopes. Counts that remain unchanged in the table still hide corrected targets: they previously pointed to declarations. Chalk contributes two package-import aliases and eleven relative ESM imports/re-exports from its implementation, tests, examples and benchmark. The broader ESM scope was added after independently verifying those eleven existing references; it is not a claim about CommonJS directories.

The complete import records for kleur (10 files / 23 references), Commander #1763's head (149 / 174), and the adapted Node fixture (6 / 6) are unchanged. Commander retains the separate relative-require gap addressed by #1214. File records, function measurements and parse coverage stay unchanged across all seven scans, with zero parse errors. Packaged raw facts equal source-checkout facts byte for byte in every scan.

Both new regressions fail on the parent and pass on the candidate; the final module suite passes 233/233 with no skips. It covers declaration inclusion/exclusion, adjacent TS implementation files, missing/excluded JS targets, unchanged TS/type-only selection and post-capture mutation. An earlier focused run under macOS's symlinked temporary path hit the existing configuration-mutation test's path-spelling mismatch; the same failure reproduces on the parent. The final suite uses the physical temporary directory, as in prior validation rounds. No product change was made for that environment issue.

TS adapter revision is 6. Two canonical Node 22.23.2 archive builds have SHA256 8ab3ecd38629b295fabd40d9dee2bd3e167bb5a4a276cda1888ff92158342202. The published commit was fetched into a fresh checkout and the following four source/archive comparisons were rerun successfully. Node resolves the target paths without executing either analyzed application.

Reproduce the published JavaScript implementation follow-up
#!/usr/bin/env bash
set -euo pipefail
jsentries_tmp=$(mktemp -d)
printf 'Reproduction directory: %s\n' "$jsentries_tmp"
pin() {
  git init -q "$jsentries_tmp/$1"
  git -C "$jsentries_tmp/$1" fetch -q --no-tags --depth=1 "https://github.com/$2.git" "$3"
  git -C "$jsentries_tmp/$1" checkout -q --detach FETCH_HEAD
}
pin archify tt-a1i/archify 06dd7340c44fc2bebafbbb897b86f9f789d303a1
pin dependency-cruiser sverweij/dependency-cruiser e6517db02e169f3db5c838bb1565ebd0fefeb452
pin chalk chalk/chalk 72110019e147683f60e13fa0ff5c14a0c27cd9fe
(cd "$jsentries_tmp/archify/archify/modules/code-analysis" && npm ci --ignore-scripts --no-audit --no-fund)
unzip -q "$jsentries_tmp/archify/archify.zip" -d "$jsentries_tmp/unpacked"
node --experimental-import-meta-resolve --input-type=module - "$jsentries_tmp" <<'JS'
import fs from 'node:fs';
import path from 'node:path';
import assert from 'node:assert/strict';
import {spawnSync} from 'node:child_process';
import {pathToFileURL,fileURLToPath} from 'node:url';
const w=process.argv[2];
const defaults=JSON.parse(fs.readFileSync(path.join(w,'archify/archify/modules/code-analysis/config/defaults.json'),'utf8'));
for(const project of ['dependency-cruiser','chalk']) {
  for(const declarations of [false,true]) {
    const root=fs.realpathSync(path.join(w,project));
    const config=project==='dependency-cruiser'
      ? {include:['src/**/*.mjs','bin/**/*.mjs','test/api.spec.mjs','test/report/mermaid/mermaid.spec.mjs',...(declarations?['types/**/*.d.mts']:[])]}
      : {exclude:defaults.exclude.filter(x=>!declarations||x!=='**/*.d.ts')};
    const configFile=path.join(w,project+'-'+declarations+'.json');
    fs.writeFileSync(configFile,JSON.stringify(config));
    const name=project+'-'+declarations;
    for(const [label,cli] of [
      ['source',path.join(w,'archify/archify/bin/archify.mjs')],
      ['package',path.join(w,'unpacked/archify/bin/archify.mjs')],
    ]) {
      const out=path.join(w,name+'-'+label);
      const result=spawnSync(process.execPath,[cli,'code-analysis','check',root,'--language','ts','--fail-on','never','--config',configFile,'--out',out,'--json'],{encoding:'utf8',timeout:180000});
      fs.writeFileSync(out+'.stdout.json',result.stdout);
      assert.equal(result.status,0,result.stderr||result.stdout);
    }
    const source=fs.readFileSync(path.join(w,name+'-source/raw-facts.json'),'utf8');
    assert.equal(source,fs.readFileSync(path.join(w,name+'-package/raw-facts.json'),'utf8'));
    const facts=JSON.parse(source);
    assert.equal(facts.repository.adapterRevision,6);
    const edges=facts.imports.filter(e=>/\.[cm]?js$/.test(e.from) && (project==='dependency-cruiser'
      ? e.specifier.startsWith('dependency-cruiser')
      : ['#ansi-styles','#supports-color'].includes(e.specifier)||e.specifier.endsWith('/index.js')));
    assert.equal(edges.length,project==='dependency-cruiser'?6:13);
    for(const edge of edges) {
      const target=fileURLToPath(import.meta.resolve(edge.specifier,pathToFileURL(path.join(root,edge.from)).href));
      assert.equal(edge.to,path.relative(root,target).split(path.sep).join('/'));
    }
    console.log(project,{declarations,matched:edges.length,packagedFactsMatch:true});
  }
}
JS

Expected: six matches for dependency-cruiser and thirteen for chalk, each with declarations excluded and included; every line reports packagedFactsMatch: true.

Remaining gaps, with counterexamples

  1. Declarations can conceal executable entries. The six real self-imports in dependency-cruiser's API tests are still unresolved in the selected source scope. Its export map has types entries before import. TypeScript finds declaration files outside the selected set, while Node independently resolves all six imports to captured .mjs implementation files. Expanding the selected set to include declarations would answer a type-dependency question; it would not establish the runtime target. The JavaScript cases are addressed by the follow-up above. TypeScript and JSX source-resolution semantics remain outside that fallback. This is separate from #1214's relative CommonJS directory-entry fix.
  2. Matched null conditions can fall through. With "#blocked": {"import": null, "default": "./fallback.cjs"}, TypeScript 5.9.3 resolves an ESM reference to the fallback. Node rejects it. The mode fix does not repair this resolver behavior. The same class of discrepancy is discussed in TypeScript #62439. A directly null-mapped import is covered by the passing negative control; the nested conditional-null case is retained as a separate failing research case.
  3. Runtime context remains bounded. User conditions, custom loaders, generated outputs and execution success are not established by these scans. The analyzer does not execute the analyzed application. Source location, type dependency and actual runtime target need to remain distinguishable in follow-up work.

Reproduce the published candidate

Requires Git, curl, unzip and Node 22. The commands install the locked analyzer dependencies only in a temporary checkout and in its unpacked archive. The five Node fixture files are fetched at an immutable commit. The two small callers below are authored for this validation. Only those callers and the inspected string-export fixture modules are executed.

#!/usr/bin/env bash
set -euo pipefail
nodeconditions_tmp=$(mktemp -d)
printf 'Reproduction directory: %s\n' "$nodeconditions_tmp"
git init -q "$nodeconditions_tmp/archify"
git -C "$nodeconditions_tmp/archify" fetch -q --depth=1 https://github.com/tt-a1i/archify.git f160d1deb412a16f5eccb58af7130740d69607dd
git -C "$nodeconditions_tmp/archify" checkout -q --detach FETCH_HEAD
(cd "$nodeconditions_tmp/archify/archify/modules/code-analysis" && npm ci --ignore-scripts --no-audit --no-fund)
mkdir "$nodeconditions_tmp/fixture"
for file in package.json importer.js importbranch.js requirebranch.js test.js; do
  curl -fsSL "https://raw.githubusercontent.com/nodejs/node/aa4c77582be995286fc6e00aaf530dc7ade102a9/test/fixtures/es-modules/pkgimports/$file" > "$nodeconditions_tmp/fixture/$file"
done
cat > "$nodeconditions_tmp/fixture/probe.cjs" <<'JS'
const branch = require('#branch');
import('#branch').then(m => console.log(JSON.stringify([branch, m.default])));
JS
cat > "$nodeconditions_tmp/fixture/probe.mjs" <<'JS'
import branch from '#branch';
export {default} from '#branch';
console.log(branch);
JS
cli="$nodeconditions_tmp/archify/archify/bin/archify.mjs"
node "$cli" code-analysis check "$nodeconditions_tmp/fixture" --language ts --fail-on never --json --out "$nodeconditions_tmp/source-out" > "$nodeconditions_tmp/source-receipt.json"
node - "$nodeconditions_tmp" <<'JS'
const assert = require('node:assert/strict');
const path = require('node:path');
const {spawnSync} = require('node:child_process');
const root = process.argv[2];
const facts = require(path.join(root, 'source-out/raw-facts.json'));
assert.equal(facts.repository.adapterRevision, 5);
assert.deepEqual(facts.imports.filter(e => e.specifier === '#branch').map(e => [e.from,e.kind,e.to]), [
  ['probe.cjs','require','requirebranch.js'],
  ['probe.cjs','dynamic','importbranch.js'],
  ['probe.mjs','static','importbranch.js'],
  ['probe.mjs','export','importbranch.js'],
]);
for (const [file, expected] of [['probe.cjs','["requirebranch","importbranch"]'],['probe.mjs','importbranch']]) {
  const actual = spawnSync(process.execPath, [path.join(root,'fixture',file)], {encoding:'utf8'});
  assert.equal(actual.status, 0, actual.stderr);
  assert.equal(actual.stdout.trim(), expected);
}
console.log('Four conditional references agree with independent Node execution.');
JS
unzip -q "$nodeconditions_tmp/archify/archify.zip" -d "$nodeconditions_tmp/unpacked"
node "$nodeconditions_tmp/unpacked/archify/bin/archify.mjs" code-analysis check "$nodeconditions_tmp/fixture" --language ts --fail-on never --json --out "$nodeconditions_tmp/package-out" > "$nodeconditions_tmp/package-receipt.json"
cmp "$nodeconditions_tmp/source-out/raw-facts.json" "$nodeconditions_tmp/package-out/raw-facts.json"
printf 'Published archive and source raw facts match.\n'

Expected output includes Four conditional references agree with independent Node execution. and Published archive and source raw facts match. On the Labs base, the require reference is correct, while both ESM references and the CJS dynamic import point to requirebranch.js.

Prepared with Codex assistance under @tt-a1i's direction. This record separates source inspection, authored probes, adapted upstream fixtures and real-project compatibility checks; it does not establish production completeness or a measured improvement in user outcomes.

Conditional-null upstream follow-up

The direct resolver comparison now includes published TypeScript 6.0.3. It correctly blocks the authored {import: null, default: './fallback.cjs'} case, while locked 5.9.3 selects the fallback. However, 6.0.3 also stops at null inside an array, where Node continues to later alternatives.

This is independently reproduced with Node's fixed upstream pkgexports manifest and asdf.js. Those two files were copied unchanged; the import caller was authored. Importing pkg-exports/fallbackfile succeeds under official Node 22.23.2. TypeScript 5.9.3 selects asdf.js; 6.0.3 leaves it unresolved. The upstream array contains empty, null, object and invalid-target alternatives before the valid file.

Boundary Node 22.23.2 TypeScript 5.9.3 TypeScript 6.0.3
Matched object condition maps to null Reject Incorrect fallback Reject
Null within the upstream fallback array Load asdf.js Resolve asdf.js Incorrectly unresolved

The implementation reason is visible in the 6.0 resolver: null produces a blocking result; the array loop also returns that result. The experiment additionally checked a nested condition, an empty array and an inactive condition.

The upgrade was not adopted. No production dependency or shipped archive changed in this follow-up, and no new PR was opened for it. The prior nested-null limitation remains explicit. The occurrence of this array form in application repositories has not been measured; the upstream fixture establishes a valid resolution boundary, not its prevalence. Retain both cases when evaluating a future fix.

Reproduce both boundaries with released packages and pinned Node source

Requires Node 22, npm, curl and tar. Dependencies are unpacked into a fresh temporary directory. Python and Archify installation are not involved.

#!/usr/bin/env bash
set -euo pipefail
w=$(mktemp -d)
for version in 5.9.3 6.0.3; do
  npm pack "typescript@$version" --ignore-scripts --pack-destination "$w" --json > "$w/npm-$version.json"
  mkdir "$w/ts-$version"
  tar -xzf "$w/typescript-$version.tgz" -C "$w/ts-$version"
done
mkdir "$w/pkgexports"
for file in package.json asdf.js; do
  curl --fail --silent --show-error "https://raw.githubusercontent.com/nodejs/node/aa4c77582be995286fc6e00aaf530dc7ade102a9/test/fixtures/node_modules/pkgexports/$file" > "$w/pkgexports/$file"
done
node - "$w" <<'JS'
const fs=require('node:fs'),path=require('node:path'),assert=require('node:assert/strict'),{spawnSync}=require('node:child_process');
const w=process.argv[2],conditional=path.join(w,'conditional');fs.mkdirSync(conditional);
fs.writeFileSync(path.join(conditional,'package.json'),JSON.stringify({type:'module',imports:{'#blocked':{import:null,default:'./fallback.cjs'}}}));
fs.writeFileSync(path.join(conditional,'fallback.cjs'),'exports.value=1;');
const cases=[{root:conditional,specifier:'#blocked',expectedNode:1,expected5:'fallback.cjs',expected6:null},{root:path.join(w,'pkgexports'),specifier:'pkg-exports/fallbackfile',expectedNode:0,expected5:'asdf.js',expected6:null}];
for(const c of cases){
 const entry=path.join(c.root,'entry.mjs');fs.writeFileSync(entry,`import ${JSON.stringify(c.specifier)};\n`);
 const run=spawnSync(process.execPath,[entry],{cwd:c.root,encoding:'utf8'});assert.equal(run.status,c.expectedNode,run.stderr);
 if(run.status)assert.match(run.stderr,/ERR_PACKAGE_IMPORT_NOT_DEFINED/);
 const targets={};
 for(const[version,expected]of [['5.9.3',c.expected5],['6.0.3',c.expected6]]){
  const ts=require(path.join(w,'ts-'+version,'package/lib/typescript.js'));
  const r=ts.resolveModuleName(c.specifier,entry,{allowJs:true,module:ts.ModuleKind.NodeNext,moduleResolution:ts.ModuleResolutionKind.NodeNext},ts.sys,undefined,undefined,ts.ModuleKind.ESNext).resolvedModule;
  targets[version]=r?path.relative(c.root,r.resolvedFileName):null;assert.equal(targets[version],expected);
 }
 console.log(JSON.stringify({specifier:c.specifier,nodeExit:run.status,targets}));
}
JS

Clone this wiki locally