Skip to content

Code Analysis Default Calls

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

Relative default function calls

Draft #1227 extends call evidence and source questions to direct default function declarations. This is a documented coverage extension: the earlier v1 contract intentionally excluded default imports. It remains outside Labs/dev; no PR has been merged.

Candidate c4374a78fd57e27c137673e624e4b3aa4ed7c5ff, parent a16d94f78d2ae6b0b92d2e3785781d7e1944554a. Native stack #1221 is #1219 → #1220 → #1225 → #1226 → #1227, with unchanged lower heads.

Source-first expectations and results

For Redux c9e06506, the import declarations, calls and exported functions were read before running the candidate. All six expected targets are now recorded:

Callsite Direct default declaration
combineReducers.ts:33, createStore.ts:271, utils/isAction.ts:6 isPlainObject at line 5
combineReducers.ts:133 and 173 warning at line 6
applyMiddleware.ts:70, the inner compose call compose implementation at line 46, after its overload signatures

The outer call to the function returned by compose(...)(store.dispatch) remains unresolved. These are lexical source targets, not proof of which objects execute. A source question selecting isAction now offers valid navigation to isPlainObject; it still requires source reading to explain validation behavior.

Fixed scope Files Imports Calls Changed call records
Redux src c9e06506 17 39 87 6
EventEmitter3 a6505491, full root 16 87 1,085 0
Archify Labs bf0a29e7, full root 607 3,447 112,145 0

All existing targets and file/import/function/parse facts are unchanged. All three scopes have zero parse errors. The archive matches raw facts byte for byte. These controls do not establish general binding accuracy, runtime completeness or reader benefit.

Contract and implementation

The extractor reuses its current TypeScript checker, recorded relative-import target and measured function nodes. There is no new parser, resolver or dependency. Default imports and default as bind only a direct named or anonymous default function declaration with exactly one implementation body. Overload signatures do not become targets. Type-only imports, ambiguous or missing bodies, excluded/invalid/declaration files, default export expressions, export aliases/re-exports, packages and namespaces stay unresolved. See TypeScript's type-only import contract.

js-ts-lexical-v2 and extraction revision 9 make the changed coverage explicit. The new question consumer accepts v1 and v2 with separate binding-coverage labels. Reading a genuine saved v1 capture keeps its unresolved calls; no targets are inferred retroactively. An older v1-only question consumer reports v2 as unavailable with a null total. Schema acceptance is unchanged. Freshness checks remain separate for entry and target files.

The binding regression and v2 assertion fail on the parent; 12 other selected cases already pass there. All 23 focused and 265 module tests pass on the candidate. Existing negative and compatibility cases were extended instead of creating another fixture family. The archive was built twice with official Node 22.23.2; SHA256 e0fec7fb0a9bbc890f7926cf6aaa43deb1963d21fec4f1d4536639b2860184a3. Current CI results are on the Draft; local results do not establish merge readiness.

Reproduce

A fresh public fetch reproduced all three scans and all three compatibility questions with Node 22.23.2. The script below fetches fixed public commits and installs locked dependencies in temporary checkouts. It checks the six preselected targets, preserved facts, two control projects, v1/v2 question compatibility, raw-facts archive parity and question parity after normalizing only the bundled workflow document's location. It does not execute the analyzed applications. Requires Git, unzip and Node 22.

#!/usr/bin/env bash
set -euo pipefail
w=$(mktemp -d)
printf '%s\n' "$w"
pin() {
  git init -q "$w/$1"
  git -c http.version=HTTP/1.1 -C "$w/$1" fetch -q --no-tags --depth=1 "https://github.com/$2.git" "$3"
  git -C "$w/$1" checkout -q --detach FETCH_HEAD
}
pin analyzer tt-a1i/archify c4374a78fd57e27c137673e624e4b3aa4ed7c5ff
pin parent tt-a1i/archify a16d94f78d2ae6b0b92d2e3785781d7e1944554a
pin source tt-a1i/archify bf0a29e7664f52973d06a8e6a608df2db1ecbd39
pin redux reduxjs/redux c9e06506f88926e252daf5275495eba0c04bf8e3
pin eventemitter3 primus/eventemitter3 a650549176ded0d0dcc52e27b03ae5669d7dcd7c
unzip -q "$w/analyzer/archify.zip" -d "$w/unpacked"
for tool in analyzer parent unpacked; do
  (cd "$w/$tool/archify/modules/code-analysis" && npm ci --ignore-scripts --no-audit --no-fund)
done
export EVIDENCE_ROOT="$w/evidence"
export PARENT_CLI="$w/parent/archify/bin/archify.mjs"
export REDUX_ROOT="$w/redux/src"
export EMITTER_ROOT="$w/eventemitter3"
export ARCHIFY_ROOT="$w/source"
cat > "$w/validate.mjs" <<'DEFAULT_CALL_VALIDATOR'
import fs from 'node:fs';
import path from 'node:path';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';

const e = process.env.EVIDENCE_ROOT || path.dirname(fileURLToPath(import.meta.url));
const cli = process.argv[2];
const label = process.argv[3] || 'candidate';
const parentCLI = process.env.PARENT_CLI;
const projects = [
  { name: 'redux', root: process.env.REDUX_ROOT, revision: 'c9e06506f88926e252daf5275495eba0c04bf8e3' },
  { name: 'eventemitter3', root: process.env.EMITTER_ROOT, revision: 'a650549176ded0d0dcc52e27b03ae5669d7dcd7c' },
  { name: 'archify', root: process.env.ARCHIFY_ROOT, revision: 'bf0a29e7664f52973d06a8e6a608df2db1ecbd39' },
];
const expected = [
  ['applyMiddleware.ts', 70, 'compose<typeof dispatch>(...chain)', 'compose.ts', 'compose', 46],
  ['combineReducers.ts', 33, 'isPlainObject(inputState)', 'utils/isPlainObject.ts', 'isPlainObject', 5],
  ['combineReducers.ts', 133, 'warning(`No reducer provided for key "${key}"`)', 'utils/warning.ts', 'warning', 6],
  ['combineReducers.ts', 173, 'warning(warningMessage)', 'utils/warning.ts', 'warning', 6],
  ['createStore.ts', 271, 'isPlainObject(action)', 'utils/isPlainObject.ts', 'isPlainObject', 5],
  ['utils/isAction.ts', 6, 'isPlainObject(action)', 'utils/isPlainObject.ts', 'isPlainObject', 5],
];
const sha = value => createHash('sha256').update(typeof value === 'string' || Buffer.isBuffer(value) ? value : JSON.stringify(value)).digest('hex');
const read = name => JSON.parse(fs.readFileSync(name, 'utf8'));
function command(program, args) {
  const r = spawnSync(program, args, { encoding: 'utf8', timeout: 240000, maxBuffer: 8 * 1024 * 1024 });
  assert.equal(r.status, 0, r.stderr || r.stdout || String(r.error));
  return r;
}
function scan(program, p, which) {
  const out = path.join(e, `${p.name}-${which}`);
  const r = command(process.execPath, [program, 'code-analysis', 'check', p.root, '--language', 'ts', '--fail-on', 'never', '--out', out, '--json']);
  fs.writeFileSync(`${out}.receipt.json`, r.stdout); fs.writeFileSync(`${out}.stderr`, r.stderr);
  return read(path.join(out, 'raw-facts.json'));
}
function originalFacts({ calls, callModel, repository: { adapterRevision, snapshotId, ...repository }, ...facts }) {
  return { ...facts, repository };
}
fs.mkdirSync(e, { recursive: true });
const results = [];
for (const p of projects) {
  assert.equal(command('git', ['-C', p.root, 'rev-parse', 'HEAD']).stdout.trim(), p.revision);
  assert.equal(command('git', ['-C', p.root, 'status', '--porcelain', '--untracked-files=no']).stdout, '');
  const before = label === 'candidate' ? scan(parentCLI, p, 'parent') : read(`${e}/${p.name}-parent/raw-facts.json`);
  const after = scan(cli, p, label);
  assert.equal(before.callModel, 'js-ts-lexical-v1'); assert.equal(after.callModel, 'js-ts-lexical-v2');
  assert.equal(after.repository.adapterRevision, 9);
  assert.equal(sha(originalFacts(after)), sha(originalFacts(before)), `${p.name}: existing file/import/function/parse facts changed`);
  assert.equal(after.calls.length, before.calls.length);
  const changed = after.calls.flatMap((call, i) => sha(call) === sha(before.calls[i]) ? [] : [{ before: before.calls[i], after: call }]);
  if (p.name === 'redux') {
    assert.equal(changed.length, expected.length);
    for (const [i, [file, line, text, targetFile, name, targetLine]] of expected.entries()) {
      const sourceLine = fs.readFileSync(path.join(p.root, file), 'utf8').split('\n')[line - 1];
      const column = sourceLine.indexOf(text) + 1;
      assert.ok(column > 0, `${file}:${line}: source expectation missing`);
      const range = { file, line, column, endLine: line, endColumn: column + text.length };
      assert.deepEqual(changed[i].before, { ...range, resolution: 'unresolved', reason: 'no-local-function-binding' });
      assert.deepEqual(changed[i].after, { ...range, resolution: 'imported-function', target: { file: targetFile, name, line: targetLine, column: 1 } });
    }
    const chained = after.calls.filter(c => c.file === 'applyMiddleware.ts' && c.line === 70);
    assert.equal(chained.length, 2);
    assert.equal(chained.filter(c => c.resolution === 'unresolved' && c.reason === 'unsupported-callee').length, 1);
  } else assert.deepEqual(changed, [], `${p.name}: control bindings changed`);
  if (label === 'archive') assert.equal(sha(fs.readFileSync(`${e}/${p.name}-${label}/raw-facts.json`)), sha(fs.readFileSync(`${e}/${p.name}-candidate/raw-facts.json`)), `${p.name}: archive raw-facts byte parity`);
  results.push({ name: p.name, sourceRevision: p.revision, files: after.files.length, imports: after.imports.length, functions: after.functions.length, calls: after.calls.length, changedCalls: changed.length, parseErrors: (after.parse_errors || []).length, previousFactsUnchanged: true, previousTargetsUnchanged: true, ...(label === 'archive' ? { archiveParity: true } : {}) });
  console.log(JSON.stringify(results.at(-1)));
}
const redux = projects[0];
const questionResults = [];
for (const [name, producer, consumer, expectedModel, coverage, target] of [
  ['new-model', 'candidate', cli, 'js-ts-lexical-v2', 'available', true],
  ['saved-v1', 'parent', cli, 'js-ts-lexical-v1', 'available', false],
  ['older-reader', 'candidate', parentCLI, 'js-ts-lexical-v2', 'unavailable', false],
]) {
  const out = `${e}/${label}-question-${name}.json`;
  const r = command(process.execPath, [consumer, 'code-analysis', 'task', `${e}/redux-${producer}`, '--question', 'Where is the plain-object validation defined, and what remains unresolved?', '--entry', 'utils/isAction.ts', '--function', 'isAction', '--root', redux.root, '--format', 'json', '--out', out, '--json']);
  fs.writeFileSync(`${out}.receipt.json`, r.stdout); fs.writeFileSync(`${out}.stderr`, r.stderr);
  const brief = read(out), receipt = JSON.parse(r.stdout);
  assert.equal(brief.stage, 'orientation-only'); assert.equal(brief.entry.factsStatus, 'valid');
  assert.equal(brief.calls.coverage, coverage); assert.equal(receipt.calls.coverage, coverage);
  assert.equal(brief.calls.provenance.callModel, expectedModel); assert.equal(receipt.calls.model, expectedModel);
  assert.equal(brief.calls.total, coverage === 'available' ? 1 : null); assert.equal(receipt.calls.total, brief.calls.total);
  if (target) {
    assert.equal(brief.calls.records[0].fact.resolution, 'imported-function');
    assert.equal(brief.calls.records[0].targetNavigation.status, 'valid');
    assert.deepEqual(brief.calls.records[0].targetNavigation.location, { file: 'utils/isPlainObject.ts', name: 'isPlainObject', line: 5, column: 1 });
  } else if (coverage === 'available') {
    assert.equal(brief.calls.bindingCoverage, 'declarations-const-initializers-and-relative-named-imports');
    assert.equal(brief.calls.records[0].fact.resolution, 'unresolved');
  } else assert.deepEqual(brief.calls.records, []);
  const normalize = ({ workflow, ...body }) => body;
  if (label === 'archive') assert.deepEqual(normalize(brief), normalize(read(`${e}/candidate-question-${name}.json`)));
  questionResults.push({ name, coverage, model: expectedModel, total: brief.calls.total, targetNavigation: target, ...(label === 'archive' ? { archiveParity: true } : {}) });
}
fs.writeFileSync(`${e}/${label}-results.json`, JSON.stringify({ node: process.version, projects: results, questions: questionResults }, null, 2) + '\n');
console.log(JSON.stringify({ questions: questionResults }));
DEFAULT_CALL_VALIDATOR
node "$w/validate.mjs" "$w/analyzer/archify/bin/archify.mjs" candidate
node "$w/validate.mjs" "$w/unpacked/archify/bin/archify.mjs" archive

Inherited scope correction

Parameter-default scope verification records the inherited false-target correction and updated #1227 head 6140a1c8, v2 / revision 11. The same three project controls and three consumer boundaries pass source/archive validation. Earlier receipts above remain tied to their recorded head.

Clone this wiki locally