/**
 * U3 (#2083 M3) — pure taint propagation engine.
 *
 * Fixtures parse REAL source through the harvest + match harness (the
 * model-match.test.ts pattern): CFGs and SiteRecords come from the worker-side
 * TS CFG visitor, def→use facts from the real reaching-defs solver, and
 * matches from the real import-aware matcher — `computeTaintFlows` consumes
 * the exact structures U4 will feed it, never hand-built mocks.
 *
 * Sanitizer semantics under test (the KIND-SET exclusion model, which
 * subsumes the plan's binary kill — see propagate.ts module doc): a def
 * produced through a sanitizer is tainted-with-exclusions; a sink fires
 * unless its kind is in the taint's accumulated neutralized set. Mechanics
 * tests therefore use a custom spec whose `escape` neutralizes the test
 * sink's own kind (so "killed" scenarios read like the plan's binary
 * scenarios); the kind-set tests at the bottom exercise the real built-in
 * model where neutralization is deliberately kind-scoped.
 */

import { describe, it, expect } from 'vitest';
import { cfgOf, importsFor } from '../../helpers/ts-cfg-harness.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import {
  computeReachingDefs,
  type FunctionDefUse,
  type ReachingDefsLimits,
} from '../../../src/core/ingestion/cfg/reaching-defs.js';
import { hasTaintSafeSites } from '../../../src/core/ingestion/taint/site-safety.js';
import type { SourceSinkSanitizerSpec } from '../../../src/core/ingestion/taint/source-sink-config.js';
import { TS_JS_TAINT_MODEL } from '../../../src/core/ingestion/taint/typescript-model.js';
import {
  buildTaintImportIndex,
  matchFunctionSites,
} from '../../../src/core/ingestion/taint/match.js';
import {
  computeTaintFlows,
  type FunctionTaintResult,
  type TaintLimits,
} from '../../../src/core/ingestion/taint/propagate.js';

/** The single binding index for `name` (throws when shadowed/ambiguous). */
function bindingIdx(cfg: FunctionCfg, name: string): number {
  const idxs = (cfg.bindings ?? []).map((b, i) => (b.name === name ? i : -1)).filter((i) => i >= 0);
  if (idxs.length !== 1) throw new Error(`expected 1 binding for ${name}, got ${idxs.length}`);
  return idxs[0];
}

/**
 * Mechanics spec: a global `exec` sink and a global `escape` sanitizer that
 * neutralizes the SAME kind — so interposition/kill mechanics behave like the
 * plan's binary-kill scenarios while still flowing through the kind-set model.
 */
const MECH: SourceSinkSanitizerSpec = {
  sources: [
    {
      kind: 'remote-input',
      objects: ['req'],
      properties: ['body', 'query', 'params', 'headers'],
    },
  ],
  sinks: [{ name: 'exec', kind: 'command-injection', args: [0], global: true }],
  sanitizers: [{ name: 'escape', neutralizes: ['command-injection'], global: true }],
};

interface AnalyzeOptions {
  fnIndex?: number;
  spec?: SourceSinkSanitizerSpec;
  limits?: TaintLimits;
  factLimits?: ReachingDefsLimits;
}

function analyze(code: string, opts: AnalyzeOptions = {}): FunctionTaintResult {
  const cfg = cfgOf(code, opts.fnIndex ?? 0);
  expect(hasTaintSafeSites(cfg)).toBe(true);
  const defUse = computeReachingDefs(cfg, opts.factLimits);
  const matches = matchFunctionSites(
    cfg,
    opts.spec ?? MECH,
    buildTaintImportIndex(importsFor(code)),
  );
  return computeTaintFlows(cfg, defUse, matches, opts.limits);
}

/** Hop summaries `name@line` (`name@line*` when viaCall) for readable asserts. */
function hopSummary(r: FunctionTaintResult, findingIdx = 0): string[] {
  const f = r.findings[findingIdx];
  if (!f) throw new Error(`no finding at index ${findingIdx}`);
  return f.hops.map((h) => `${h.name}@${h.point.line}${h.viaCall ? '*' : ''}`);
}

// ── statuses and the empty case ──────────────────────────────────────────────

describe('coverage-gap statuses (R4)', () => {
  it('a truncated FunctionDefUse yields a coverage-gap result with zero findings', () => {
    const r = analyze(
      `function f(req) {
        const b = req.body;
        const c = b;
        exec(c);
      }`,
      { factLimits: { maxFacts: 1 } },
    );
    expect(r.status).toBe('coverage-gap');
    expect(r.gapReason).toBe('truncated');
    expect(r.findings).toHaveLength(0);
    expect(r.kills).toHaveLength(0);
  });

  it('a no-facts FunctionDefUse (no binding table) yields a coverage-gap result', () => {
    const cfg = cfgOf(`function f(req) { exec(req.body); }`);
    const { bindings: _bindings, ...noBindings } = cfg;
    const defUse = computeReachingDefs(noBindings as FunctionCfg);
    expect(defUse.status).toBe('no-facts');
    const matches = matchFunctionSites(cfg, MECH, buildTaintImportIndex([]));
    const r = computeTaintFlows(cfg, defUse, matches);
    expect(r.status).toBe('coverage-gap');
    expect(r.gapReason).toBe('no-facts');
    expect(r.findings).toHaveLength(0);
  });

  it('an overflow FunctionDefUse yields a coverage-gap result (contract input shape)', () => {
    const cfg = cfgOf(`function f(req) { exec(req.body); }`);
    const overflow: FunctionDefUse = {
      status: 'overflow',
      bindings: cfg.bindings ?? [],
      facts: [],
      defCount: 0,
      useCount: 0,
    };
    const matches = matchFunctionSites(cfg, MECH, buildTaintImportIndex([]));
    const r = computeTaintFlows(cfg, overflow, matches);
    expect(r.status).toBe('coverage-gap');
    expect(r.gapReason).toBe('overflow');
  });

  it('no sources and no sinks → computed, empty result', () => {
    const r = analyze(`function f(x) { const y = x; return y; }`);
    expect(r.status).toBe('computed');
    expect(r.findings).toHaveLength(0);
    expect(r.kills).toHaveLength(0);
    expect(r.droppedFindings).toBe(0);
  });
});

// ── rule (b): statement-local source→sink ────────────────────────────────────

describe('rule (b) — statement-local findings', () => {
  it('exec(req.body) → one finding with a single hop', () => {
    const r = analyze(`function f(req) {
      exec(req.body);
    }`);
    expect(r.status).toBe('computed');
    expect(r.findings).toHaveLength(1);
    const f = r.findings[0];
    expect(f.sinkKind).toBe('command-injection');
    expect(f.source.property).toBe('body');
    expect(f.source.point.line).toBe(2);
    expect(f.sink.point.line).toBe(2);
    expect(f.sink.argIndex).toBe(0);
    expect(f.hops).toHaveLength(1);
    expect(f.hops[0].name).toBe('req');
  });

  it('exec(req.body, req.query) → TWO findings distinguished by occurrence (KTD6 identity)', () => {
    const spec: SourceSinkSanitizerSpec = {
      ...MECH,
      sinks: [{ name: 'exec', kind: 'command-injection', args: [0, 1], global: true }],
    };
    const r = analyze(`function f(req) { exec(req.body, req.query); }`, { spec });
    expect(r.findings).toHaveLength(2);
    const ids = r.findings.map((f) => `${f.source.property}@arg${f.sink.argIndex}`);
    expect(ids).toEqual(['body@arg0', 'query@arg1']);
  });

  it('exec(req.body.toString()) → finding via the statement-local rule', () => {
    const r = analyze(`function f(req) { exec(req.body.toString()); }`);
    expect(r.findings).toHaveLength(1);
    expect(r.findings[0].source.property).toBe('body');
  });

  it('a source read at an UNREGISTERED sink position produces no finding', () => {
    const r = analyze(`function f(req) { exec('ls', req.body); }`);
    expect(r.findings).toHaveLength(0);
  });
});

// ── rule (a): happy path and chains ──────────────────────────────────────────

describe('rule (a) — worklist over def→use facts', () => {
  it('same-block flow: const b = req.body; exec(b) → finding with hop chain', () => {
    const r = analyze(`function f(req) {
      const b = req.body;
      exec(b);
    }`);
    expect(r.findings).toHaveLength(1);
    expect(hopSummary(r)).toEqual(['b@2', 'b@3']);
    expect(r.findings[0].source.property).toBe('body');
    expect(r.findings[0].sink.point.line).toBe(3);
  });

  it('reassignment chain carries variables per hop: b@L2 → c@L3 → sink@L4', () => {
    const r = analyze(`function f(req) {
      const b = req.body;
      const c = b;
      exec(c);
    }`);
    expect(r.findings).toHaveLength(1);
    expect(hopSummary(r)).toEqual(['b@2', 'c@3', 'c@4']);
  });

  it('cross-block flow through a branch reaches the sink', () => {
    const r = analyze(`function f(req, cond) {
      const b = req.body;
      let c = '';
      if (cond) {
        c = b;
      }
      exec(c);
    }`);
    expect(r.findings).toHaveLength(1);
    expect(hopSummary(r)).toEqual(['b@2', 'c@5', 'c@7']);
  });
});

// ── sanitizer interposition and kills (KTD4 both clauses) ───────────────────

describe('sanitizers — interposition, kill locality, kind sets (mechanics spec)', () => {
  it('seed interposition: const b = escape(req.body) → no finding, SANITIZES kill on b', () => {
    const r = analyze(`function f(req) {
      const b = escape(req.body);
      exec(b);
    }`);
    expect(r.findings).toHaveLength(0);
    expect(r.kills).toHaveLength(1);
    const cfg = cfgOf(`function f(req) {
      const b = escape(req.body);
      exec(b);
    }`);
    expect(r.kills[0].bindingIdx).toBe(bindingIdx(cfg, 'b'));
    expect(r.kills[0].sanitizer.line).toBe(2);
    expect([...r.kills[0].neutralized]).toEqual(['command-injection']);
  });

  it('sink interposition: exec(escape(x)) with x tainted → no finding, kill recorded', () => {
    const r = analyze(`function f(req) {
      const x = req.body;
      exec(escape(x));
    }`);
    expect(r.findings).toHaveLength(0);
    expect(r.kills).toHaveLength(1);
    expect(r.kills[0].sanitizer.line).toBe(3);
    expect([...r.kills[0].neutralized]).toEqual(['command-injection']);
  });

  it('bypass occurrence: const c = cond ? escape(x) : x → finding (direct path bypasses)', () => {
    // The ternary's escape call deliberately gets NO resultDefs (U1) — c is
    // floor-tainted with the EMPTY exclusion set (intersection over paths:
    // the direct `x` arm contributes ∅, so ∅ ∩ {command-injection} = ∅).
    const r = analyze(`function f(req, cond) {
      const x = req.body;
      const c = cond ? escape(x) : x;
      exec(c);
    }`);
    expect(r.findings).toHaveLength(1);
    expect(r.kills).toHaveLength(0);
  });

  it('intra-statement bypass at the sink: exec(x + escape(x)) → finding (plain occurrence wins)', () => {
    const r = analyze(`function f(req) {
      const x = req.body;
      exec(x + escape(x));
    }`);
    expect(r.findings).toHaveLength(1);
    // a BYPASSED sanitizer killed nothing — no SANITIZES record
    expect(r.kills).toHaveLength(0);
  });

  it('kill locality (KTD4b): const c = escape(b); exec(b) → finding on b AND a kill on c', () => {
    const code = `function f(req) {
      const b = req.body;
      const c = escape(b);
      exec(b);
    }`;
    const r = analyze(code);
    expect(r.findings).toHaveLength(1);
    expect(hopSummary(r)).toEqual(['b@2', 'b@4']);
    expect(r.kills).toHaveLength(1);
    expect(r.kills[0].bindingIdx).toBe(bindingIdx(cfgOf(code), 'c'));
  });

  it('sanitizer self-assign: b = escape(b); exec(b) → no finding, one kill', () => {
    const r = analyze(`function f(req) {
      let b = req.body;
      b = escape(b);
      exec(b);
    }`);
    expect(r.findings).toHaveLength(0);
    expect(r.kills).toHaveLength(1);
    expect(r.kills[0].sanitizer.line).toBe(3);
  });

  it('a sanitizer reached only through a SPREAD argument does not neutralize (position unprovable)', () => {
    // `escape(...arr)` — the runtime argument positions are unknowable, so
    // claiming the sanitized position received the taint would risk a false
    // kill. Sound direction: taint flows through un-neutralized.
    const r = analyze(`function f(req) {
      const arr = [req.body];
      const b = escape(...arr);
      exec(b);
    }`);
    expect(r.findings).toHaveLength(1);
    expect(r.kills).toHaveLength(0);
  });

  it('conditional sanitizer does NOT suppress: if (cond) { b = escape(b) } exec(b) → finding', () => {
    const r = analyze(`function f(req, cond) {
      let b = req.body;
      if (cond) {
        b = escape(b);
      }
      exec(b);
    }`);
    expect(r.findings).toHaveLength(1);
    // the seed def's flow survives the may-path; the sanitized def is killed
    expect(hopSummary(r)).toEqual(['b@2', 'b@6']);
    expect(r.kills).toHaveLength(1);
  });
});

// ── loop semantics: zero-iteration pair, fixpoint termination ───────────────

describe('loops — kill keyed on the def point, monotone termination (R3)', () => {
  it('zero-iteration while: the cond-false exit carries the seed def → finding SURVIVES', () => {
    const r = analyze(`function f(req, c) {
      let x = req.body;
      while (c) {
        x = escape(x);
      }
      exec(x);
    }`);
    expect(r.findings).toHaveLength(1);
    expect(hopSummary(r)).toEqual(['x@2', 'x@6']);
    expect(r.kills).toHaveLength(1);
  });

  it('do-while: the body always runs → no finding (and escape-in-loop does not re-taint)', () => {
    const r = analyze(`function f(req, c) {
      let x = req.body;
      do {
        x = escape(x);
      } while (c);
      exec(x);
    }`);
    expect(r.findings).toHaveLength(0);
    expect(r.kills).toHaveLength(1);
  });

  it('loop self-taint terminates: x = x + t in a for loop → finding, bounded', () => {
    const r = analyze(`function f(req) {
      const t = req.body;
      let x = '';
      for (let i = 0; i < 3; i++) {
        x = x + t;
      }
      exec(x);
    }`);
    expect(r.status).toBe('computed');
    expect(r.findings).toHaveLength(1);
  });

  it('assign-and-test: if ((m = re.exec(s)) && m) exec(m) → finding (self-fact handled)', () => {
    const r = analyze(`function f(req, re) {
      const s = req.body;
      let m;
      if ((m = re.exec(s)) && m) {
        exec(m);
      }
    }`);
    expect(r.findings).toHaveLength(1);
  });
});

// ── propagate-through unmodeled calls (KTD5) ─────────────────────────────────

describe('propagate-through — unmodeled calls and receivers, viaCall marks', () => {
  it('const y = helper(t); exec(y) → finding with a viaCall hop', () => {
    const r = analyze(`function f(req) {
      const t = req.body;
      const y = helper(t);
      exec(y);
    }`);
    expect(r.findings).toHaveLength(1);
    expect(hopSummary(r)).toEqual(['t@2', 'y@3*', 'y@4']);
  });

  it('receiver propagation: const cmd = t.trim(); exec(cmd) → finding (TITO)', () => {
    const r = analyze(`function f(req) {
      const t = req.body;
      const cmd = t.trim();
      exec(cmd);
    }`);
    expect(r.findings).toHaveLength(1);
    expect(hopSummary(r)).toEqual(['t@2', 'cmd@3*', 'cmd@4']);
  });

  it('tainted occurrence nested in an unmodeled call AT the sink fires: exec(helper(t))', () => {
    const r = analyze(`function f(req) {
      const t = req.body;
      exec(helper(t));
    }`);
    expect(r.findings).toHaveLength(1);
    // the sink hop records that the occurrence flowed through a call
    const sinkHop = r.findings[0].hops.at(-1);
    expect(sinkHop?.viaCall).toBe(true);
  });

  it('sanitized through-call: const y = helper(escape(x)); exec(y) → NO finding', () => {
    // Deliberate precision choice over flat-conservative (plan KTD5): the only
    // occurrence path into the unmodeled call traverses the sanitizer, so the
    // neutralization rides through the call into y's exclusion set.
    const r = analyze(`function f(req) {
      const x = req.body;
      const y = helper(escape(x));
      exec(y);
    }`);
    expect(r.findings).toHaveLength(0);
  });
});

// ── statement-coalescing precision floor ─────────────────────────────────────

describe('precision floor — multi-declarator conflation (documented FP)', () => {
  it('const a = clean(z), b = g(t); exec(a) → finding EXISTS (expected false positive)', () => {
    // PINNED FP (plan risk table): statement facts conflate declarators — `t`
    // is used by the statement and `a` is def'd by it, so `a` is floor-tainted
    // even though `t` flows only into `g(...)`. The per-declarator resultDefs
    // precision powers KILLS only (U1 note); widening it to taint attribution
    // would be an unsound narrowing of the substrate's statement granularity.
    const r = analyze(`function f(req, z) {
      const t = req.body;
      const a = clean(z), b = g(t);
      exec(a);
    }`);
    expect(r.findings).toHaveLength(1);
  });

  it('the floor never KILLS: a def in a sanitizer resultDefs with no taint inflow is floor-tainted', () => {
    // `a = escape(z)` — the tainted `t` never flows into escape, so no kill is
    // recorded for it and `a` is floor-tainted with NO exclusions (sound: a
    // kill requires evidence of flow through the sanitizer).
    const r = analyze(`function f(req, z) {
      const t = req.body;
      const a = escape(z), b = g(t);
      exec(a);
    }`);
    expect(r.findings).toHaveLength(1);
    expect(r.kills).toHaveLength(0);
  });
});

// ── sequence-expression value semantics (review fix) ────────────────────────

describe('sequence expressions — only the final operand carries taint', () => {
  it('exec((log(x), "safe")) with tainted x → NO finding (safe operand flows)', () => {
    const r = analyze(`function f(req) { const x = req.body; exec((log(x), 'safe')); }`);
    expect(r.findings).toHaveLength(0);
  });

  it('exec((log("a"), x)) with tainted x → finding (tainted final operand)', () => {
    const r = analyze(`function f(req) { const x = req.body; exec((log('a'), x)); }`);
    expect(r.findings).toHaveLength(1);
  });
});

// ── source-discriminated taint state (review fix: multi-source merge) ───────

describe('multi-source identity — distinct sources do not merge at one def', () => {
  // A spec with two source properties and a sql sanitizer, so the same-source
  // ∅-intersection case has a kind to neutralize.
  const MULTI: SourceSinkSanitizerSpec = {
    sources: [{ kind: 'remote-input', objects: ['req'], properties: ['body', 'query'] }],
    sinks: [
      { name: 'exec', kind: 'command-injection', args: [0], global: true },
      { name: 'query', kind: 'sql-injection', args: [0], anyReceiver: true },
    ],
    sanitizers: [{ name: 'escape', neutralizes: ['sql-injection'], global: true }],
  };

  it('cond ? req.body : req.query into one var → TWO findings (one per source)', () => {
    const r = analyze(`function f(req, cond) { const x = cond ? req.body : req.query; exec(x); }`, {
      spec: MULTI,
    });
    expect(r.findings).toHaveLength(2);
    const props = r.findings.map((f) => f.source.property).sort();
    expect(props).toEqual(['body', 'query']);
  });

  it('req.body + req.query into one var → TWO findings', () => {
    const r = analyze(`function f(req) { const x = req.body + req.query; exec(x); }`, {
      spec: MULTI,
    });
    expect(r.findings).toHaveLength(2);
  });

  it('same-source two-path flow → ONE finding (one root source occurrence)', () => {
    // `req.body` is read ONCE (one source occurrence → one root identity); the
    // single tainted `x` then flows two ways into `c`. Both paths share the
    // same source discriminator, so they converge on one state for `c` and
    // produce exactly one finding — the source dimension must not split a
    // single source occurrence by downstream path.
    const r = analyze(
      `function f(req, cond) { const x = req.body; const c = cond ? id(x) : x; db.query(c); }`,
      { spec: MULTI },
    );
    expect(r.findings).toHaveLength(1);
    expect(r.findings[0].sinkKind).toBe('sql-injection');
  });

  it('one source re-derived into one sink var → ONE finding, no doubling', () => {
    // A single source reaching a single sink binding via a re-derived path
    // dedups to one finding (findingsByIdentity); the source dimension in the
    // state key must not split this same-source flow.
    const r = analyze(`function f(req, cond) { let x = req.body; if (cond) { x = x; } exec(x); }`, {
      spec: MULTI,
    });
    expect(r.findings).toHaveLength(1);
  });

  it('two sources through a loop back-edge terminate with TWO findings', () => {
    const r = analyze(
      `function f(req, n) { let x = req.body; for (let i = 0; i < n; i++) { x = x + req.query; } exec(x); }`,
      { spec: MULTI },
    );
    expect(r.status).toBe('computed');
    expect(r.findings).toHaveLength(2);
  });
});

// ── kind-set exclusion model (real built-in model) ──────────────────────────

describe('kind-set exclusions — sanitizers neutralize their kinds only', () => {
  it('built-in model catches argv-form child_process command sinks', () => {
    const r = analyze(
      `import { execFileSync } from 'node:child_process';
function f(req) {
  const tool = req.body;
  const arg = req.query;
  execFileSync(tool, ['--version']);
  execFileSync('git', [arg]);
}`,
      { spec: TS_JS_TAINT_MODEL },
    );
    expect(r.findings.map((finding) => finding.sinkKind)).toEqual([
      'command-injection',
      'command-injection',
    ]);
  });

  it('built-in model catches conventional modern DB receiver methods', () => {
    const r = analyze(
      `function f(req, db, stmt, knex) {
  const name = req.body;
  db.run(name);
  db.all(name);
  stmt.get(name);
  knex.raw(name);
}`,
      { spec: TS_JS_TAINT_MODEL },
    );
    expect(r.findings.map((finding) => finding.sinkKind)).toEqual([
      'sql-injection',
      'sql-injection',
      'sql-injection',
      'sql-injection',
    ]);
  });

  it('built-in model catches Express render template and data sinks', () => {
    const r = analyze(
      `function f(req, res) {
  const template = req.body;
  const viewData = req.query;
  res.render(template, viewData);
}`,
      { spec: TS_JS_TAINT_MODEL },
    );
    expect(r.findings.map((finding) => finding.sinkKind)).toEqual(['xss', 'xss']);
  });

  it('escape(req.body) → res.send(b) suppressed (xss neutralized) BUT db.query(b) fires (sql not)', () => {
    const r = analyze(
      `import { escape } from 'validator';
function f(req, db, res) {
  const b = escape(req.body);
  db.query(b);
  res.send(b);
}`,
      { spec: TS_JS_TAINT_MODEL },
    );
    expect(r.findings).toHaveLength(1);
    expect(r.findings[0].sinkKind).toBe('sql-injection');
    expect(r.findings[0].sink.point.line).toBe(4);
    // the seed kill is still recorded with the kinds escape neutralizes
    expect(r.kills).toHaveLength(1);
    expect([...r.kills[0].neutralized]).toEqual(['xss']);
  });

  it('kind-incompatible sink interposition: exec(path.basename(t)) → finding SURVIVES', () => {
    // basename strips directories, not shell metacharacters — a kind-blind
    // kill here would be a suppressed live command injection (the forbidden
    // false-negative direction).
    const r = analyze(
      `import { exec } from 'child_process';
import path from 'path';
function f(req) {
  const t = req.body;
  exec(path.basename(t));
}`,
      { spec: TS_JS_TAINT_MODEL },
    );
    expect(r.findings).toHaveLength(1);
    expect(r.findings[0].sinkKind).toBe('command-injection');
  });

  it('kind-compatible def interposition: const safe = path.basename(t); readFileSync(safe) → suppressed', () => {
    const r = analyze(
      `import path from 'path';
import { readFileSync } from 'fs';
function f(req) {
  const t = req.body;
  const safe = path.basename(t);
  readFileSync(safe);
}`,
      { spec: TS_JS_TAINT_MODEL },
    );
    expect(r.findings).toHaveLength(0);
    expect(r.kills).toHaveLength(1);
    expect([...r.kills[0].neutralized]).toEqual(['path-traversal']);
  });

  it('the same basename-cleaned def still fires a command-injection sink', () => {
    const r = analyze(
      `import { exec } from 'child_process';
import path from 'path';
function f(req) {
  const t = req.body;
  const safe = path.basename(t);
  exec(safe);
}`,
      { spec: TS_JS_TAINT_MODEL },
    );
    expect(r.findings).toHaveLength(1);
    expect(r.findings[0].sinkKind).toBe('command-injection');
  });

  it('exclusions intersect over derivations: a less-neutralized re-derivation re-opens the sink', () => {
    // y is first derived through escape ({xss} excluded), then re-derived
    // through the direct assignment (∅) — the intersection is ∅, so the
    // xss sink must fire. Guards the monotone shrink/re-enqueue discipline.
    const r = analyze(
      `import { escape } from 'validator';
function f(req, cond, res) {
  const b = req.body;
  let y = escape(b);
  if (cond) {
    y = b;
  }
  res.send(y);
}`,
      { spec: TS_JS_TAINT_MODEL },
    );
    // two defs of y reach the sink: the escaped one (suppressed for xss) and
    // the raw one (fires) — one finding from the raw def's flow
    expect(r.findings).toHaveLength(1);
    expect(r.findings[0].sinkKind).toBe('xss');
  });
});

// ── caps and determinism ─────────────────────────────────────────────────────

describe('caps — deterministic truncation (R6 substrate)', () => {
  const manyFindings = `function f(req) {
    exec(req.body);
    exec(req.query);
    exec(req.params);
    exec(req.headers);
  }`;

  it('maxFindingsPerFunction truncates deterministically and counts the drop', () => {
    const r = analyze(manyFindings, { limits: { maxFindingsPerFunction: 2 } });
    expect(r.findings).toHaveLength(2);
    expect(r.droppedFindings).toBe(2);
    // deterministic prefix of the sorted order: statement order
    expect(r.findings.map((f) => f.source.property)).toEqual(['body', 'query']);
  });

  it('without a cap all findings emit and droppedFindings is 0', () => {
    const r = analyze(manyFindings);
    expect(r.findings).toHaveLength(4);
    expect(r.droppedFindings).toBe(0);
  });

  it('maxHops truncates the hop chain and flags it', () => {
    const r = analyze(
      `function f(req) {
      const a = req.body;
      const b = a;
      const c = b;
      exec(c);
    }`,
      { limits: { maxHops: 2 } },
    );
    expect(r.findings).toHaveLength(1);
    expect(r.findings[0].hops).toHaveLength(2);
    expect(r.findings[0].hopsTruncated).toBe(true);
    expect(hopSummary(r)).toEqual(['a@2', 'b@3']);
  });

  it('results are deterministic across repeated runs', () => {
    const code = `function f(req, cond) {
      let x = req.body;
      const y = helper(x);
      if (cond) {
        x = escape(x);
      }
      exec(x);
      exec(y);
      exec(req.query);
    }`;
    const a = analyze(code);
    const b = analyze(code);
    expect(JSON.parse(JSON.stringify(a))).toEqual(JSON.parse(JSON.stringify(b)));
  });
});
