Skip to content

Code Analysis Optional Chains

Tu Shaokun edited this page Nov 6, 2026 · 1 revision

Optional-chain guards on CommonJS loads

This follow-up corrects a supported syntactic classification in Draft #1213. A literal require() in an optional-chain argument or computed key may be skipped when its chain base is nullish. The previous public head recorded these loads as eager, which could promote an otherwise conditional import cycle to a warning and fail the default finding gate.

This was found in authored probes, not a reported application incident. A bounded scan of pinned Commander and dependency-cruiser sources found no matching trigger. The correction reuses two existing TypeScript AST predicates; it does not introduce a control-flow graph or claim general runtime reachability.

Frozen cases and independent behavior

Each two-file fixture has a.cjs loading b.cjs in the expression below and b.cjs requiring a.cjs. Expected outcomes were fixed before running the analyzer. An inspected load counter then records whether Node 22.23.2 actually loads b.cjs, once with a nullish sink and once with an active callable/object sink.

Expression in a.cjs Nullish-sink load behavior Expected import classification Previous #1213 Corrected
sink?.(require('./b.cjs')) skipped conditional eager/warning, exit 1 deferred/info, exit 0
sink?.use(require('./b.cjs')) skipped conditional eager/warning, exit 1 deferred/info, exit 0
({use:sink}).use?.(require('./b.cjs')) skipped conditional eager/warning, exit 1 deferred/info, exit 0
sink?.[require('./b.cjs')] skipped conditional eager/warning, exit 1 deferred/info, exit 0
Existing if guard skipped conditional deferred/info, exit 0 unchanged
Direct require('./b.cjs') loads eager eager/warning, exit 1 unchanged
require('./b.cjs')?.use loads eager base eager/warning, exit 1 unchanged
(sink?.use)(require('./b.cjs')) loads before the failed call eager argument after grouped chain break eager/warning, exit 1 unchanged

The ECMAScript Optional Chains evaluation rules stop a nullish chain before its guarded argument/key evaluation. The chain base still evaluates. Grouping can end the chain: the last control evaluates the require argument before a TypeError. A blanket rule for every descendant of an expression containing ?. would therefore be incorrect.

All eight captures have zero parse errors and resolved cycle edges. Sixteen finite runtime observations agree with the expected load behavior. A deferred/info cycle remains a static classification, not proof of safe initialization; an active sink can load the peer immediately. Neither severity nor the default-gate exit measures runtime safety.

Change and version identities

The comparison is previous public #1213 5df4226925e7b77099fda86c5aba6202a6e12465 to candidate 592afacd0dc4d118a51cd92fd0a62894ee45b715, on Labs bf0a29e7664f52973d06a8e6a608df2db1ecbd39. isConditionalChild checks isCallChain arguments and isElementAccessChain keys within the existing ancestor walk. Eager prefixes, grouped boundaries, lazy function context, enclosing conditionals and the distinct dynamic-import contract are preserved. The evaluator does not change.

TS revision 12 identifies the new extraction behavior. Python remains revision 2 on this branch, with its previous implementation and generated task examples unchanged. This revision choice avoids other published experiments' numbers; it does not imply that every lower revision's feature is included.

The dependent Drafts are refreshed without changing their existing concerns: #1214 is 1b924d65c6b15c8ef98c9c09af87aed4ae3d7535 with combined TS revision 14; #1216 is 272f995b9a0a38789c1d7d8c76e73ce62636fe5b with inherited TS revision 12 and unchanged Python revision 5. All three heads are pushed and remain open Drafts. #1214 belongs to native stack #1215 with #1213; #1216 separately targets #1213 and is not an extra member of that stack.

Real controls and coverage limits

An AST inventory scan found no optional-chain require argument/key trigger in 1,200 tracked dependency-cruiser JS/TS paths (270 require sites, 357 optional-chain nodes) or 150 Commander paths (168 require sites, no optional-chain nodes). These inventory counts differ from default captured files, which exclude/filter some paths. They do not estimate production prevalence.

Normal default-config analysis was then compared on the exact public pins below, using --fail-on never to retain the complete analysis regardless of findings. Successful control exits do not mean no findings. All raw-fact content is unchanged after removing only the intended adapter revision and snapshot identity metadata.

Source control Files Import records Function records Parse errors
Commander 2c7f687e 149 174 736 0
dependency-cruiser e6517db0 1,157 1,972 1,652 10 existing errors

These older producers do not contain the later JS package-entry/call-model stack. Their record totals must not be substituted for that other branch's results. No target application or project test suite was executed for these controls.

Validation and distribution

On official Node 22.23.2/Python 3.13.16, all 50 focused checks and all 232 module tests pass. The positive regression fails on the exact previous head while eager controls already pass there. Two behavioral tests extend the existing import helper and assert flags plus downstream severity/kind; no source-string or exact-prose test is added. Independent scoped review found no actionable issue and five extra Node probes passed, including mixed guarded/eager loads within one expression.

The lower archive was built twice with identical SHA256 eb33ac19f315813ed0f3350d08d0b3d40c0c9a84a36e77201ab6cc1c517382ac. Source and unpacked-archive raw facts match byte for byte in all eight authored and both real scopes. A fresh companion-file public replay passed, reproducing the four parent mismatches and all ten candidate/archive scopes. A subsequent standalone script, run from a directory containing only that file, also passed its first attempt with the same embedded checks. Final CI completed on #1213, #1214 and #1216: each has the sole README Classic digest regression already reproduced on exact Labs, with matching expected/actual hashes, and the resulting aggregate Node22 failure. All other executed jobs pass. CI is not green. This remains Draft work, not human-approved or integrated behavior.

Dependent-branch preservation

  • #1214: 61 affected checks pass. All eight authored and four historical Redux/Commander source sides match source/archive raw facts. Each Commander side retains 125 independent Node require-resolution agreements; CJS resolver source is exactly the previous implementation plus the inherited guard and revision metadata. Fresh archive rebuild matches SHA256 806e4895a102b9c0cd4ad5b8be1a2ba434cd0bd8555e0152e646b0ec6b1a975f.
  • #1216: all 237 module tests pass. Python extractor, generated task fixtures and Python revision/digests are unchanged from 48d31661fe941f7b1985c61484268e71fda6674e. The 52-file/108-import Ansible collection control is byte-identical to the previous capture through source and archive CLIs; the eight inherited authored cases also match. The previous ten-scope Python study is reused on that basis; its other nine external scopes and Python runtime oracles were not rerun. Archive SHA256 2d79b0ac0af0cd03762b3a7afe41092029dd73fae64f3b0157af0d71b4c1e2ea matches a fresh rebuild.

Initial evidence-harness errors are retained separately: the Commander check needed canonical macOS paths, and a preservation assertion first overlooked #1216's existing Python tests. Correcting those assertions required no product change. Completed capture outputs were reused where applicable.

Public reproduction

Run the following executed standalone script with official Node 22.23.2 on PATH (Git, npm, unzip and shasum are required). It uses pinned public analyzer/target sources and temporary analyzer dependencies. Only the small inspected authored fixtures are executed; real project sources are analyzed without running their applications.

Executed standalone public reproduction
#!/bin/sh
# Fixed published revisions and ZIP digest; override only for a deliberate new validation.
set -eu
FINAL_CANDIDATE_SHA=${FINAL_CANDIDATE_SHA:-592afacd0dc4d118a51cd92fd0a62894ee45b715}
ARCHIVE_SHA256=${ARCHIVE_SHA256:-eb33ac19f315813ed0f3350d08d0b3d40c0c9a84a36e77201ab6cc1c517382ac}
test "$(node --version)" = v22.23.2 || { echo "Node 22.23.2 required" >&2; exit 1; }
WORK=$(mktemp -d "${TMPDIR:-/tmp}/archify-cycle-repro.XXXXXX")
printf 'Workspace: %s\n' "$WORK"
pin() { git init -q "$WORK/$1"; git -C "$WORK/$1" fetch -q --no-tags --depth=1 "https://github.com/$2.git" "$3"; git -C "$WORK/$1" checkout -q --detach FETCH_HEAD; test "$(git -C "$WORK/$1" rev-parse HEAD)" = "$3"; }
pin parent tt-a1i/archify 5df4226925e7b77099fda86c5aba6202a6e12465
pin candidate tt-a1i/archify "$FINAL_CANDIDATE_SHA"
pin commander tj/commander.js 2c7f687eaea6e7b29b917db8d71a06fe6fbfb53e
pin dependency-cruiser sverweij/dependency-cruiser e6517db02e169f3db5c838bb1565ebd0fefeb452
for a in parent candidate; do (cd "$WORK/$a/archify/modules/code-analysis" && npm ci --ignore-scripts --no-audit --no-fund); done
printf '%s  %s\n' "$ARCHIVE_SHA256" "$WORK/candidate/archify.zip" | shasum -a 256 -c -
mkdir "$WORK/archive"; unzip -q "$WORK/candidate/archify.zip" -d "$WORK/archive"
# Archive includes archify/ at its root; install only its locked analyzer dependencies.
(cd "$WORK/archive/archify/modules/code-analysis" && npm ci --ignore-scripts --no-audit --no-fund)
cd "$WORK"
cat > 'compare.mjs' <<'ARCHIFY_EMBED_COMPARE_MJS'
import fs from 'node:fs';
import path from 'node:path';
import assert from 'node:assert/strict';
import {spawnSync} from 'node:child_process';
const [parentCli,candidateCli,commander,cruiser,label='candidate']=process.argv.slice(2);
if(!parentCli||!candidateCli||!commander||!cruiser) throw Error('usage: node compare.mjs PARENT_CLI CANDIDATE_CLI COMMANDER_ROOT CRUISER_ROOT [LABEL]');
const targets=[['commander',commander,'2c7f687eaea6e7b29b917db8d71a06fe6fbfb53e'],['dependency-cruiser',cruiser,'e6517db02e169f3db5c838bb1565ebd0fefeb452']];
const outputs=[];
for(const [name,root,pin] of targets){
 const git=spawnSync('git',['-C',root,'rev-parse','HEAD'],{encoding:'utf8'}); assert.equal(git.status,0,git.stderr);assert.equal(git.stdout.trim(),pin);
 const status=spawnSync('git',['-C',root,'status','--porcelain','--untracked-files=all'],{encoding:'utf8'});assert.equal(status.status,0,status.stderr);assert.equal(status.stdout,'',`${name} must be clean`);
 const captures=[];
 for(const [which,cli] of [['parent',parentCli],[label,candidateCli]]){
  const dir=path.resolve(`${label}-${name}-${which}`);fs.mkdirSync(dir,{recursive:true});
  const r=spawnSync(process.execPath,[cli,'code-analysis','check',root,'--language','ts','--fail-on','never','--json','--out',dir],{encoding:'utf8',timeout:180000,maxBuffer:20*1024*1024});
  fs.writeFileSync(`${dir}-receipt.json`,r.stdout);fs.writeFileSync(`${dir}-stderr.txt`,r.stderr);assert.equal(r.status,0,`${name}/${which}: ${r.stderr}`);
  const facts=JSON.parse(fs.readFileSync(path.join(dir,'raw-facts.json'),'utf8'));
  const revision=facts.repository.adapterRevision, snapshot=facts.repository.snapshotId;
  delete facts.repository.adapterRevision;delete facts.repository.snapshotId;
  captures.push({facts,revision,snapshot,receipt:JSON.parse(r.stdout)});
 }
 assert.deepEqual(captures[1].facts,captures[0].facts,`${name}: raw facts differ beyond adapterRevision/snapshotId`);
 const f=captures[0].facts;
 outputs.push({name,pin,root,parentRevision:captures[0].revision,candidateRevision:captures[1].revision,counts:Object.fromEntries(['files','packageMarkers','imports','symbols','functions','calls','unresolved'].map(k=>[k,Array.isArray(f[k])?f[k].length:null])),parseErrors:(f.parse_errors??[]).length,receiptParent:captures[0].receipt,receiptCandidate:captures[1].receipt,rawFactsEqualExcept:['repository.adapterRevision','repository.snapshotId']});
}
fs.writeFileSync(`${label}-results.json`,JSON.stringify({node:process.version,outputs},null,2)+'\n');console.log(JSON.stringify(outputs.map(({name,counts,parseErrors,parentRevision,candidateRevision})=>({name,counts,parseErrors,parentRevision,candidateRevision})),null,2));
ARCHIFY_EMBED_COMPARE_MJS
cat > 'authored.mjs' <<'ARCHIFY_EMBED_AUTHORED_MJS'
import fs from 'node:fs';import path from 'node:path';import assert from 'node:assert/strict';import {spawnSync} from 'node:child_process';import {createHash} from 'node:crypto';
const [cli,label='candidate']=process.argv.slice(2);if(!cli)throw Error('usage: node authored.mjs CLI [LABEL]');
const frozen=fs.readFileSync(new URL('./expected.json',import.meta.url));assert.equal(createHash('sha256').update(frozen).digest('hex'),'622dc86395ce6156699835499b187430d883ddba5c262794d789f12e39438813');const expected=JSON.parse(frozen);let result=[];
for(const item of expected.cases){const root=path.resolve(`authored-${label}-${item.id}`);fs.mkdirSync(root,{recursive:true});fs.writeFileSync(path.join(root,'a.cjs'),`const sink = globalThis.sink ?? null;\n${item.expression}\n`);fs.writeFileSync(path.join(root,'b.cjs'),`globalThis.bLoads = (globalThis.bLoads || 0) + 1;\nmodule.exports = {use(){}};\nrequire('./a.cjs');\n`);
 let runtime=[];for(const active of [false,true]){let setup=active?'globalThis.sink=Object.assign(function(){},{use(){}});':'globalThis.sink=null;';let r=spawnSync(process.execPath,['-e',setup+`let error=null;try{require('./a.cjs')}catch(e){error=e.name}console.log(JSON.stringify({bLoads:globalThis.bLoads||0,error}))`],{cwd:root,encoding:'utf8'});assert.equal(r.status,0,r.stderr);let o=JSON.parse(r.stdout);assert.equal(o.bLoads,active?1:Number(item.bLoadsWithNullSink),item.id);runtime.push(o);}
 const dir=path.resolve(`authored-output-${label}-${item.id}`);const r=spawnSync(process.execPath,[cli,'code-analysis','check',root,'--language','ts','--json','--out',dir],{encoding:'utf8',timeout:20000});fs.writeFileSync(`${dir}-receipt.json`,r.stdout);fs.writeFileSync(`${dir}-stderr.txt`,r.stderr);const guarded=item.conditional && !(label==='parent' && item.id.startsWith('optional-'));assert.equal(r.status,guarded?0:1,`${item.id}: ${r.stderr}`);let f=JSON.parse(fs.readFileSync(path.join(dir,'raw-facts.json')));let findings=JSON.parse(fs.readFileSync(path.join(dir,'findings.json')));let edge=f.imports.find(x=>x.from==='a.cjs'&&x.to==='b.cjs');assert.ok(edge,item.id);assert.equal(Boolean(edge.conditional),guarded,item.id);let cycles=findings.diagnostics.filter(x=>x.code==='coupling/import-cycle');assert.equal(cycles.length,1);assert.equal(cycles[0].severity,guarded?'info':'warning',item.id);assert.equal(cycles[0].evidence.kind,guarded?'deferred':'eager',item.id);result.push({id:item.id,edge,cycle:cycles[0].severity,kind:cycles[0].evidence.kind,exit:r.status,runtime});}
fs.writeFileSync(`authored-${label}-results.json`,JSON.stringify(result,null,2)+'\n');console.log(JSON.stringify({cases:result.length,runtimeObservations:result.length*2}));
ARCHIFY_EMBED_AUTHORED_MJS
cat > 'expected.json' <<'ARCHIFY_EMBED_EXPECTED_JSON'
{
  "frozenAt": "2026-11-06T11:55:13.424867+00:00",
  "analyzer": "5df4226925e7b77099fda86c5aba6202a6e12465",
  "node": "v22.23.2",
  "cases": [
    {
      "id": "optional-call",
      "expression": "sink?.(require(\"./b.cjs\"));",
      "conditional": true,
      "bLoadsWithNullSink": false,
      "expectedCycleSeverity": "info"
    },
    {
      "id": "optional-member-call",
      "expression": "sink?.use(require(\"./b.cjs\"));",
      "conditional": true,
      "bLoadsWithNullSink": false,
      "expectedCycleSeverity": "info"
    },
    {
      "id": "optional-invocation",
      "expression": "({use:sink}).use?.(require(\"./b.cjs\"));",
      "conditional": true,
      "bLoadsWithNullSink": false,
      "expectedCycleSeverity": "info"
    },
    {
      "id": "optional-key",
      "expression": "sink?.[require(\"./b.cjs\")];",
      "conditional": true,
      "bLoadsWithNullSink": false,
      "expectedCycleSeverity": "info"
    },
    {
      "id": "if-control",
      "expression": "if(sink) require(\"./b.cjs\");",
      "conditional": true,
      "bLoadsWithNullSink": false,
      "expectedCycleSeverity": "info"
    },
    {
      "id": "direct-control",
      "expression": "require(\"./b.cjs\");",
      "conditional": false,
      "bLoadsWithNullSink": true,
      "expectedCycleSeverity": "warning"
    },
    {
      "id": "chain-prefix-control",
      "expression": "require(\"./b.cjs\")?.use;",
      "conditional": false,
      "bLoadsWithNullSink": true,
      "expectedCycleSeverity": "warning"
    },
    {
      "id": "grouped-call-control",
      "expression": "(sink?.use)(require(\"./b.cjs\"));",
      "conditional": false,
      "bLoadsWithNullSink": true,
      "expectedCycleSeverity": "warning"
    }
  ],
  "contract": "CommonJS literal relative requires are captured; their optional-chain argument/key guard is conditional, matching existing if/short-circuit policy. Guarded info is not an import-safety guarantee.",
  "primaryOracle": "https://tc39.es/ecma262/multipage/ecmascript-language-expressions.html#sec-optional-chains-runtime-semantics-evaluation"
}
ARCHIFY_EMBED_EXPECTED_JSON
node authored.mjs "$WORK/parent/archify/bin/archify.mjs" parent
node authored.mjs "$WORK/candidate/archify/bin/archify.mjs" candidate
node authored.mjs "$WORK/archive/archify/bin/archify.mjs" archive
node compare.mjs "$WORK/parent/archify/bin/archify.mjs" "$WORK/candidate/archify/bin/archify.mjs" "$WORK/commander" "$WORK/dependency-cruiser" candidate
node compare.mjs "$WORK/parent/archify/bin/archify.mjs" "$WORK/archive/archify/bin/archify.mjs" "$WORK/commander" "$WORK/dependency-cruiser" archive
node --input-type=module <<'JS'
import fs from 'node:fs';import assert from 'node:assert/strict';
for (const name of ['commander','dependency-cruiser']) {
 const candidate=fs.readFileSync(`candidate-${name}-candidate/raw-facts.json`);
 const archive=fs.readFileSync(`archive-${name}-archive/raw-facts.json`);
 assert.ok(candidate.equals(archive),`${name} candidate/archive raw-facts mismatch`);
}
for (const name of ['optional-call','optional-member-call','optional-invocation','optional-key','if-control','direct-control','chain-prefix-control','grouped-call-control']) {
 const candidate=fs.readFileSync(`authored-output-candidate-${name}/raw-facts.json`);
 const archive=fs.readFileSync(`authored-output-archive-${name}/raw-facts.json`);
 assert.ok(candidate.equals(archive),`${name} candidate/archive raw-facts mismatch`);
}
console.log('Source/archive raw facts agree on eight authored and two real scopes.');
JS

Clone this wiki locally