/**
 * Generic incomplete-mediation oracle.  NO hardcoded predicate/sink NAMES.
 *
 * The asymmetry LOGIC is unchanged from oracle.ts. What changes: RECOGNITION is
 * derived, not hardcoded —
 *   - GUARD: a callee is an identity-predicate iff its RESOLVED body (in-file or
 *     cross-file import) compares two non-literal inputs with ===/!==/==/!=.
 *     The guarded pair = the principals in the CALL's arguments. Plus inline === .
 *   - SINK : a call with an object-literal arg carrying BOTH an amount-like field
 *     and a beneficiary-id field (shape, not function name). Sign from the amount.
 *   - EARN : any positive-amount sink (no entryType allow-list).
 *   - INTER: family-relevant principals = union of principals in any family guard
 *     pair (no 'owner' literal).
 *
 * GATE: rename every predicate (def + call sites) to a meaningless token and the
 * catch must survive — proving body-resolution, not name-matching.
 */
import ts from 'typescript';
import { readFileSync } from 'fs';
import { dirname, resolve } from 'path';

const FILE = process.argv[2] ?? 'service.ts';

const sfCache = new Map<string, ts.SourceFile>();
function load(path: string): ts.SourceFile | null {
  if (sfCache.has(path)) return sfCache.get(path)!;
  let src: string;
  try { src = readFileSync(path, 'utf8'); } catch { return null; }
  const sf = ts.createSourceFile(path, src, ts.ScriptTarget.Latest, true);
  sfCache.set(path, sf);
  return sf;
}

const sfMaybe = load(FILE);
if (!sfMaybe) throw new Error(`oracle2: cannot read source file: ${FILE}`);
const sf: ts.SourceFile = sfMaybe;
const lineOf = (n: ts.Node, owner: ts.SourceFile = sf) =>
  owner.getLineAndCharacterOfPosition(n.getStart(owner)).line + 1;

// ---- principals: named-role *UserId from value-flow, independent of guards ----
function canonPrincipal(name: string): string | null {
  const m = /^(.*?)(UserId|UserID)$/i.exec(name);
  if (!m) return null;
  const p = m[1].toLowerCase();
  if (p === '') return null;            // generic userId = row-owner, not a role
  if (p === 'buyer') return 'referee';  // buyer ≡ referee actor
  return p;
}
const rawCanonExpr = (n: ts.Expression): string | null =>
  ts.isPropertyAccessExpression(n) ? canonPrincipal(n.name.text)
  : ts.isIdentifier(n) ? canonPrincipal(n.text) : null;

// ---- principal ALIAS UNIFICATION (union-find) — two names for one principal -----------------
// A binding `KEY: SRC.principal` or `const KEY = SRC.principal` makes KEY and SRC the SAME
// principal (e.g. SELECT alias `vendorOwnerUserId: vendors.ownerUserId` ⟹ 'vendorowner' ≡ 'owner').
// Without this, one entity under two names floods INTRA with phantom unguarded pairs and mis-fires
// INTER ("sibling sink never references the other name"). Representative = lexicographically least
// member — deterministic + order-independent so the same principal canonicalizes identically across
// every function and file.
const parent = new Map<string, string>();
function find(x: string): string {
  let r = x;
  while (parent.has(r) && parent.get(r) !== r) r = parent.get(r)!;
  let c = x;                                                   // path-compress to root
  while (parent.has(c) && parent.get(c) !== r) { const nx = parent.get(c)!; parent.set(c, r); c = nx; }
  return r;
}
function union(a: string, b: string) {
  const ra = find(a), rb = find(b);
  if (ra === rb) return;
  const lo = ra < rb ? ra : rb, hi = ra < rb ? rb : ra;       // least stays root
  parent.set(hi, lo);
}
const repr = (p: string): string => find(p);
{
  const scanAlias = (n: ts.Node) => {
    if (ts.isPropertyAssignment(n) && ts.isIdentifier(n.name)) {
      const k = canonPrincipal(n.name.text), v = rawCanonExpr(n.initializer);
      if (k && v) union(k, v);
    } else if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.initializer) {
      const k = canonPrincipal(n.name.text), v = rawCanonExpr(n.initializer);
      if (k && v) union(k, v);
    }
    ts.forEachChild(n, scanAlias);
  };
  scanAlias(sf);
}

function principalsIn(node: ts.Node): Set<string> {
  const out = new Set<string>();
  const add = (name: string) => { const c = canonPrincipal(name); if (c) out.add(repr(c)); };
  const visit = (n: ts.Node) => {
    if (ts.isPropertyAccessExpression(n)) add(n.name.text);
    else if (ts.isShorthandPropertyAssignment(n)) add(n.name.text);
    else if (ts.isIdentifier(n)) add(n.text);
    ts.forEachChild(n, visit);
  };
  visit(node);
  return out;
}
const key2 = (a: string, b: string) => [a, b].sort().join('|');
const canonOfExpr = (n: ts.Expression): string | null => { const r = rawCanonExpr(n); return r ? repr(r) : null; };

// ---- import map: imported name -> resolved .ts path (for cross-file bodies) ----
const importMap = new Map<string, string>();
sf.statements.forEach((st) => {
  if (ts.isImportDeclaration(st) && ts.isStringLiteral(st.moduleSpecifier)) {
    const spec = st.moduleSpecifier.text;
    if (!spec.startsWith('.')) return;
    const path = resolve(dirname(FILE), spec.replace(/\.js$/, '.ts'));
    const nb = st.importClause?.namedBindings;
    if (nb && ts.isNamedImports(nb)) nb.elements.forEach((e) => importMap.set(e.name.text, path));
  }
});

// imported callees whose body could NOT be resolved because the import file did not load.
// A predicate hiding in such a file is invisible → its sink reads as unguarded → a SILENT result
// is a possible FALSE CLEAN. Surfaced in the report so the gate never treats it as a clean pass.
const unresolvedImports = new Map<string, string>();

// ---- find a function/arrow definition body for a callee name (in-file then import) ----
function findDefBody(name: string): ts.Node | null {
  let found: ts.Node | null = null;
  const scan = (owner: ts.SourceFile) => {
    owner.statements.forEach((st) => {
      if (ts.isFunctionDeclaration(st) && st.name?.text === name && st.body) found = st.body;
      if (ts.isVariableStatement(st))
        st.declarationList.declarations.forEach((d) => {
          if (ts.isIdentifier(d.name) && d.name.text === name && d.initializer &&
              (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer)))
            found = d.initializer.body;
        });
    });
  };
  scan(sf);
  if (!found && importMap.has(name)) {
    const p = importMap.get(name)!;
    const ext = load(p);
    if (ext) scan(ext); else unresolvedImports.set(name, p);  // import file absent → body invisible
  }
  return found;
}

// ---- identity predicate? + POLARITY: body compares two NON-LITERAL inputs for identity ----
// 'eq'  = returns true ON collision (body uses ===/==)   e.g. isSelf(a,b){return a===b}
// 'neq' = returns true on NON-collision (body uses !==/!=) e.g. isDifferent(a,b){return a!==b}
// null  = not an identity predicate.
const predCache = new Map<string, 'eq' | 'neq' | null>();
const EQ_OPS = new Set([ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.EqualsEqualsToken]);
const NEQ_OPS = new Set([ts.SyntaxKind.ExclamationEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken]);
const ID_OPS = new Set([...EQ_OPS, ...NEQ_OPS]);
const nonLiteralOperand = (n: ts.Expression) =>
  ts.isIdentifier(n) || ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n);
function predicatePolarity(name: string): 'eq' | 'neq' | null {
  if (predCache.has(name)) return predCache.get(name)!;
  const body = findDefBody(name);
  let ans: 'eq' | 'neq' | null = null;
  if (body) {
    const visit = (n: ts.Node) => {
      if (ans) return;
      if (ts.isBinaryExpression(n) && ID_OPS.has(n.operatorToken.kind) &&
          nonLiteralOperand(n.left) && nonLiteralOperand(n.right))
        ans = EQ_OPS.has(n.operatorToken.kind) ? 'eq' : 'neq';
      ts.forEachChild(n, visit);
    };
    visit(body);
  }
  predCache.set(name, ans);
  return ans;
}
const isIdentityPredicate = (name: string): boolean => predicatePolarity(name) !== null;

// ---- guards in a service fn body: resolved identity-predicate calls + inline === ----
type Guard = { pair: string; a: string; b: string; line: number; via: string };
function extractGuards(body: ts.Node): Guard[] {
  const guards: Guard[] = [];
  const visit = (n: ts.Node) => {
    if (ts.isCallExpression(n)) {
      const callee = ts.isPropertyAccessExpression(n.expression) ? n.expression.name.text
        : ts.isIdentifier(n.expression) ? n.expression.text : '';
      if (callee && isIdentityPredicate(callee)) {
        const ps = [...principalsIn(n)];
        for (let i = 0; i < ps.length; i++)
          for (let j = i + 1; j < ps.length; j++)
            guards.push({ pair: key2(ps[i], ps[j]), a: ps[i], b: ps[j], line: lineOf(n), via: callee });
      }
    }
    if (ts.isBinaryExpression(n) && ID_OPS.has(n.operatorToken.kind)) {
      const la = canonOfExpr(n.left), lb = canonOfExpr(n.right);
      const op = EQ_OPS.has(n.operatorToken.kind) ? '===' : '!==';     // show the real operator (polarity matters)
      if (la && lb && la !== lb) guards.push({ pair: key2(la, lb), a: la, b: lb, line: lineOf(n), via: op });
    }
    ts.forEachChild(n, visit);
  };
  visit(body);
  return guards;
}

// ---- sinks by SHAPE: object-literal arg with amount-like + beneficiary fields ----
type Sink = { entryType: string; sign: 'pos' | 'zero' | 'debit'; line: number; beneficiary: string; via: string; node: ts.CallExpression };
const AMOUNT_RE = /(amount|agorot|cents|value|sum|total|price|fee)/i;
const BENEF_RE = /(UserId|UserID|accountId|accountID|walletId|walletID)$/i;
const TYPE_RE = /^(entrytype|type|kind)$/i;
function extractSinks(body: ts.Node): Sink[] {
  const sinks: Sink[] = [];
  const visit = (n: ts.Node) => {
    if (ts.isCallExpression(n)) {
      const objArg = n.arguments.find(ts.isObjectLiteralExpression);
      if (objArg) {
        let amountField: ts.PropertyAssignment | null = null, benef = '', entryType = '?';
        for (const p of objArg.properties) {
          if (!ts.isPropertyAssignment(p) || !ts.isIdentifier(p.name)) continue;
          const k = p.name.text;
          if (AMOUNT_RE.test(k) && !amountField) amountField = p;
          if (BENEF_RE.test(k) && !benef) benef = canonOfExpr(p.initializer) ?? p.initializer.getText(sf).slice(0, 24);
          if (TYPE_RE.test(k) && ts.isStringLiteral(p.initializer)) entryType = p.initializer.text;
        }
        // require a string-literal category field too: a value-moving RECORD write
        // (ledger/audit/txn) carries amount + beneficiary + a type discriminator.
        // This drops internal dispatcher calls that merely pass {amount, id} objects.
        if (amountField && benef && entryType !== '?') {
          let sign: Sink['sign'] = 'pos';
          const init = amountField.initializer;
          if (ts.isNumericLiteral(init)) sign = init.text === '0' ? 'zero' : 'pos';
          else if (ts.isPrefixUnaryExpression(init) && init.operator === ts.SyntaxKind.MinusToken) sign = 'debit';
          const callee = ts.isPropertyAccessExpression(n.expression) ? n.expression.name.text
            : ts.isIdentifier(n.expression) ? n.expression.text : '?';
          sinks.push({ entryType, sign, line: lineOf(n), beneficiary: benef, via: callee, node: n });
        }
      }
    }
    ts.forEachChild(n, visit);
  };
  visit(body);
  return sinks;
}

// ---- control flow: does a statement's taken branch divert flow (skip what follows)? ----
function terminates(stmt: ts.Statement | undefined): boolean {
  if (!stmt) return false;
  if (ts.isReturnStatement(stmt) || ts.isThrowStatement(stmt) ||
      ts.isContinueStatement(stmt) || ts.isBreakStatement(stmt)) return true;
  if (ts.isBlock(stmt)) return terminates(stmt.statements[stmt.statements.length - 1]);
  return false;
}

// ---- find a local `const NAME = <init>` within a function body (for boolean-var resolution) ----
function findLocalVarInit(name: string, fnBody: ts.Node): ts.Expression | null {
  let init: ts.Expression | null = null;
  const visit = (n: ts.Node) => {
    if (init) return;
    if (ts.isVariableDeclaration(n) && ts.isIdentifier(n.name) && n.name.text === name && n.initializer)
      init = n.initializer;
    ts.forEachChild(n, visit);
  };
  visit(fnBody);
  return init;
}

// ---- POLARITY: identity pairs in a boolean expression, each tagged true-on-collision -------------
// `trueOnCollision` = does the expression evaluate TRUE when the two principals are equal? We read it
// from each compare's operator / predicate body: inline ===/== → true-on-collision; !==/!= → false;
// a wrapping `!` flips it. collectPairs is the SINGLE reusable collector — used by both the
// control-flow guard logic (if / early-exit) AND amount-expression mediation (ternary). It resolves a
// condition that is itself a LOCAL boolean variable (`const self = isSelf(a,b); if (self) …` and
// `const blocked = isSelf(a,b) || isSelf(c,d); amount = blocked ? 0 : x`) by hopping to its
// initializer (cycle-guarded by `seen`). Predicate calls + inline binaries are leaves.
type RawPair = { a: string; b: string; line: number; via: string; trueOnCollision: boolean };
function collectPairs(cond: ts.Node, fnBody: ts.Node, seen: Set<string> = new Set()): RawPair[] {
  const out: RawPair[] = [];
  const push = (a: string, b: string, line: number, via: string, toc: boolean) => {
    if (a !== b) out.push({ a, b, line, via, trueOnCollision: toc });
  };
  const visit = (n: ts.Node, neg: boolean): void => {
    if (ts.isPrefixUnaryExpression(n) && n.operator === ts.SyntaxKind.ExclamationToken)
      return visit(n.operand, !neg);
    if (ts.isParenthesizedExpression(n)) return visit(n.expression, neg);
    if (ts.isAwaitExpression(n)) return visit(n.expression, neg);
    if (ts.isCallExpression(n)) {
      const callee = ts.isPropertyAccessExpression(n.expression) ? n.expression.name.text
        : ts.isIdentifier(n.expression) ? n.expression.text : '';
      const pol = callee ? predicatePolarity(callee) : null;
      if (pol) {
        const toc = neg ? pol !== 'eq' : pol === 'eq';            // true-on-collision, flipped by `!`
        const ps = [...principalsIn(n)];
        for (let i = 0; i < ps.length; i++)
          for (let j = i + 1; j < ps.length; j++) push(ps[i], ps[j], lineOf(n), callee, toc);
        return;                                                   // predicate is a leaf; don't descend
      }
    }
    if (ts.isBinaryExpression(n) && ID_OPS.has(n.operatorToken.kind)) {
      const la = canonOfExpr(n.left), lb = canonOfExpr(n.right);
      if (la && lb && la !== lb) {
        const eq = EQ_OPS.has(n.operatorToken.kind);
        push(la, lb, lineOf(n), '===', neg ? !eq : eq);
      }
      return;
    }
    if (ts.isIdentifier(n)) {                                     // resolve local boolean variable
      if (!seen.has(n.text)) {
        const init = findLocalVarInit(n.text, fnBody);
        if (init) { seen.add(n.text); return visit(init, neg); }
      }
      return;
    }
    ts.forEachChild(n, (c) => visit(c, neg));
  };
  visit(cond, false);
  return out;
}

// A guard PROTECTS a sink only if the collision case routes flow AWAY from the sink:
//   sink runs on collision ⟺ (trueOnCollision === sinkRunsWhenCondTrue). Protective ⟺ it does NOT.
// Rejects the inverted bug `if (referee !== owner) return; <sink>` (pays ON collision).
function protectiveGuardsInCondition(cond: ts.Node, sinkRunsWhenCondTrue: boolean, fnBody: ts.Node): Guard[] {
  return collectPairs(cond, fnBody)
    .filter((p) => p.trueOnCollision !== sinkRunsWhenCondTrue)
    .map((p) => ({ pair: key2(p.a, p.b), a: p.a, b: p.b, line: p.line, via: p.via }));
}

// ---- guards that CONTROL-FLOW-DOMINATE a sink (not merely "compared somewhere in the fn") ----
// A guard counts for a sink only if its identity comparison decides whether the sink runs AND
// routes the collision case away from it (polarity, via protectiveGuardsInCondition):
//   (A) ENCLOSING  — the sink is inside the if's taken (then/else) branch.
//   (B) EARLY-EXIT — a preceding-sibling `if (cond) { …; return/throw/continue }` (no else) in a
//                    containing block: the sink is reached only when cond was false → cond gates it.
// A cosmetic compare not in any gating if-condition (e.g. a log line) is therefore NOT a guard.
function guardsForSink(sink: ts.Node, fnBody: ts.Node): Guard[] {
  const out: Guard[] = [];
  let node: ts.Node = sink;
  while (node.parent) {
    const parent = node.parent;
    if (ts.isIfStatement(parent)) {                                               // (A) ENCLOSING
      if (node === parent.thenStatement) out.push(...protectiveGuardsInCondition(parent.expression, true, fnBody));
      else if (node === parent.elseStatement) out.push(...protectiveGuardsInCondition(parent.expression, false, fnBody));
    }
    if (ts.isBlock(parent) || ts.isSourceFile(parent) ||
        ts.isCaseClause(parent) || ts.isDefaultClause(parent)) {
      const stmts = parent.statements as ReadonlyArray<ts.Statement>;
      const idx = stmts.indexOf(node as ts.Statement);
      if (idx >= 0)
        for (let i = 0; i < idx; i++) {
          const prev = stmts[i];                                                  // (B) EARLY-EXIT
          if (ts.isIfStatement(prev) && !prev.elseStatement && terminates(prev.thenStatement))
            out.push(...protectiveGuardsInCondition(prev.expression, false, fnBody)); // sink runs when cond FALSE
        }
    }
    if (parent === fnBody) break;
    node = parent;
  }
  return out;
}

// ---- resolve an expression through local `const` hops (depth-capped, for amount/branch values) ----
function resolveLocalExpr(expr: ts.Expression, fnBody: ts.Node, depth = 0): ts.Expression {
  if (depth > 6) return expr;
  if (ts.isParenthesizedExpression(expr)) return resolveLocalExpr(expr.expression, fnBody, depth + 1);
  if (ts.isAwaitExpression(expr)) return resolveLocalExpr(expr.expression, fnBody, depth + 1);
  if (ts.isIdentifier(expr)) {
    const init = findLocalVarInit(expr.text, fnBody);
    if (init) return resolveLocalExpr(init, fnBody, depth + 1);
  }
  return expr;
}
// non-paying amount value: numeric literal 0, or a prefix-minus (debit). A compute() call or a
// positive literal is PAYING — conservatively NOT a block.
function isNonPaying(expr: ts.Expression, fnBody: ts.Node): boolean {
  const e = resolveLocalExpr(expr, fnBody);
  if (ts.isNumericLiteral(e)) return e.text === '0';
  if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken) return true;
  return false;
}

// ---- AMOUNT-EXPRESSION mediation: a sink whose amount is `cond ? whenTrue : whenFalse` where the
// COLLISION branch yields a non-paying value (0 / debit) is gated by the pair(s) in `cond` — exactly
// the C09 production idiom `reward = blockedSelfVendor ? 0 : compute(...)` with
// `blockedSelfVendor = isSelf(referrer,owner) || isSelf(referee,owner)`. Mediation lives in the VALUE,
// not control flow; the sink runs unconditionally with the conditionally-zeroed amount.
function amountGuardsForSink(sink: ts.CallExpression, fnBody: ts.Node): Guard[] {
  const out: Guard[] = [];
  const objArg = sink.arguments.find(ts.isObjectLiteralExpression);
  if (!objArg) return out;
  let amountInit: ts.Expression | null = null;
  for (const p of objArg.properties)
    if (ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && AMOUNT_RE.test(p.name.text)) {
      amountInit = p.initializer; break;
    }
  if (!amountInit) return out;
  const amt = resolveLocalExpr(amountInit, fnBody);
  if (!ts.isConditionalExpression(amt)) return out;
  for (const p of collectPairs(amt.condition, fnBody)) {
    const taken = p.trueOnCollision ? amt.whenTrue : amt.whenFalse;   // value selected ON collision
    if (isNonPaying(taken, fnBody))
      out.push({ pair: key2(p.a, p.b), a: p.a, b: p.b, line: p.line, via: `amount:${p.via}` });
  }
  return out;
}

// ---- collect exported service functions ----
type Fn = { name: string; line: number; body: ts.Node; principals: Set<string>; guards: Guard[]; sinks: Sink[] };
const fns: Fn[] = [];
sf.statements.forEach((st) => {
  if (ts.isFunctionDeclaration(st) && st.body && st.name)
    fns.push({ name: st.name.text, line: lineOf(st), body: st.body, principals: principalsIn(st.body),
               guards: extractGuards(st.body), sinks: extractSinks(st.body) });
});

// ============================ ORACLE (asymmetry) ============================
type Flag = { kind: string; fn: string; sink: string; line: number; detail: string };
const flags: Flag[] = [];
let valueSinkCount = 0;
const isEarn = (s: Sink) => s.sign === 'pos';

// per-sink gating guards (control-flow dominance) — the load-bearing recognition
const sinkGuards = new Map<ts.CallExpression, Set<string>>();
for (const fn of fns)
  for (const s of fn.sinks)
    sinkGuards.set(s.node, new Set([
      ...guardsForSink(s.node, fn.body),          // control-flow dominance (if / early-exit)
      ...amountGuardsForSink(s.node, fn.body),    // value-expression mediation (ternary amount)
    ].map((g) => g.pair)));

// intra-sink completeness — a sink whose flow IS gated by an identity check must gate ALL pairs
for (const fn of fns) {
  for (const s of fn.sinks) {
    if (s.sign === 'pos') valueSinkCount++;
    if (!isEarn(s)) continue;
    const guarded = sinkGuards.get(s.node)!;
    if (guarded.size === 0) continue;   // no identity check GATES this sink → intra silent (see inter)
    const P = [...fn.principals];
    for (let i = 0; i < P.length; i++)
      for (let j = i + 1; j < P.length; j++) {
        const pk = key2(P[i], P[j]);
        if (!guarded.has(pk))
          flags.push({ kind: 'INTRA-SINK GAP', fn: fn.name, sink: s.entryType, line: s.line,
            detail: `guards {${[...guarded][0]}} but NOT {${pk}} — incomplete mediation on same sink` });
      }
  }
}

// inter-sink consistency — family-relevant principals from GATING guards only, no hardcoded role
const earnFns = fns.filter((f) => f.sinks.some(isEarn));
const familyPrincipals = new Set<string>();
for (const f of earnFns)
  for (const s of f.sinks.filter(isEarn))
    for (const pair of sinkGuards.get(s.node)!) pair.split('|').forEach((p) => familyPrincipals.add(p));
for (const fn of earnFns) {
  const s = fn.sinks.find(isEarn)!;
  for (const P of familyPrincipals) {
    if (!fn.principals.has(P))
      flags.push({ kind: 'INTER-SINK GAP', fn: fn.name, sink: s.entryType, line: s.line,
        detail: `a sibling earn-sink mediates '${P}' collisions; this sink never even references '${P}' — inconsistent mediation` });
  }
}

// ================================ REPORT ================================
console.log(`\n=== EXTRACTED (generic recognition, no hardcoded names) — ${FILE} ===`);
console.log(`identity-predicates resolved: ${[...predCache].filter(([, v]) => v).map(([k]) => k).join(', ') || '(none)'}`);
{
  const members = new Set<string>([...parent.keys(), ...parent.values()]);
  const groups = new Map<string, Set<string>>();
  for (const m of members) { const r = repr(m); (groups.get(r) ?? groups.set(r, new Set()).get(r)!).add(m); }
  const aliasStr = [...groups.values()].filter((g) => g.size > 1).map((g) => `{${[...g].sort().join('≡')}}`).join(' ');
  console.log(`principal aliases unified: ${aliasStr || '(none)'}`);
}
for (const fn of fns) {
  console.log(`\nfn ${fn.name}  (L${fn.line})`);
  console.log(`  principals: {${[...fn.principals].sort().join(', ')}}`);
  console.log(`  guards    : ${fn.guards.map((g) => `${g.pair}@L${g.line}(${g.via})`).join('  ') || '(none)'}`);
  console.log(`  sinks     : ${fn.sinks.map((s) => {
    const g = [...(sinkGuards.get(s.node) ?? new Set<string>())];
    return `${s.entryType}:${s.sign}->${s.beneficiary}@L${s.line}(${s.via})${s.sign === 'pos' ? ` [gated:${g.join(',') || 'NONE'}]` : ''}`;
  }).join('  ') || '(none)'}`);
}
console.log(`\n=== FLAGS (asymmetry — hand to human triage) ===`);
if (flags.length === 0) console.log('  (none — oracle SILENT)');
for (const f of flags) { console.log(`  [${f.kind}] ${f.fn} :: ${f.sink} @L${f.line}`); console.log(`      ${f.detail}`); }
if (unresolvedImports.size) {
  // A guard could be hiding in an unloaded import; a SILENT/clean result here may be a FALSE CLEAN.
  console.log(`\n=== UNRESOLVED IMPORTS (predicate bodies invisible — result UNRELIABLE; run from full repo) ===`);
  for (const [n, p] of unresolvedImports) console.log(`  ${n}  <- ${p}  (import file did not load)`);
}
console.log(`\nvalue-positive sinks: ${valueSinkCount}   flags: ${flags.length}   flags/sink: ${valueSinkCount ? (flags.length / valueSinkCount).toFixed(2) : 'n/a'}   unresolved-imports: ${unresolvedImports.size}`);
