gitnexus 1.6.8-rc.20 → 1.6.8-rc.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/cli/ai-context.js +1 -0
  2. package/dist/cli/skill-gen.js +1 -0
  3. package/dist/core/ingestion/cfg/emit.d.ts +16 -1
  4. package/dist/core/ingestion/cfg/emit.js +10 -3
  5. package/dist/core/ingestion/cfg/reaching-defs.d.ts +7 -0
  6. package/dist/core/ingestion/cfg/reaching-defs.js +9 -0
  7. package/dist/core/ingestion/cfg/types.d.ts +91 -0
  8. package/dist/core/ingestion/cfg/visitors/typescript-harvest.d.ts +38 -0
  9. package/dist/core/ingestion/cfg/visitors/typescript-harvest.js +419 -3
  10. package/dist/core/ingestion/pipeline.d.ts +16 -0
  11. package/dist/core/ingestion/scope-resolution/pipeline/phase.js +2 -0
  12. package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +8 -0
  13. package/dist/core/ingestion/scope-resolution/pipeline/run.js +119 -3
  14. package/dist/core/ingestion/taint/emit.d.ts +124 -0
  15. package/dist/core/ingestion/taint/emit.js +204 -0
  16. package/dist/core/ingestion/taint/match.d.ts +153 -0
  17. package/dist/core/ingestion/taint/match.js +278 -0
  18. package/dist/core/ingestion/taint/path-codec.d.ts +134 -0
  19. package/dist/core/ingestion/taint/path-codec.js +190 -0
  20. package/dist/core/ingestion/taint/propagate.d.ts +216 -0
  21. package/dist/core/ingestion/taint/propagate.js +664 -0
  22. package/dist/core/ingestion/taint/site-safety.d.ts +29 -0
  23. package/dist/core/ingestion/taint/site-safety.js +98 -0
  24. package/dist/core/ingestion/taint/source-sink-config.d.ts +94 -23
  25. package/dist/core/ingestion/taint/source-sink-config.js +11 -11
  26. package/dist/core/ingestion/taint/source-sink-registry.d.ts +6 -4
  27. package/dist/core/ingestion/taint/source-sink-registry.js +6 -4
  28. package/dist/core/ingestion/taint/typescript-model.d.ts +38 -0
  29. package/dist/core/ingestion/taint/typescript-model.js +102 -0
  30. package/dist/core/run-analyze.d.ts +8 -1
  31. package/dist/core/run-analyze.js +14 -0
  32. package/dist/mcp/local/local-backend.d.ts +29 -0
  33. package/dist/mcp/local/local-backend.js +241 -1
  34. package/dist/mcp/resources.js +1 -0
  35. package/dist/mcp/tools.d.ts +9 -0
  36. package/dist/mcp/tools.js +53 -0
  37. package/dist/storage/parse-cache.js +10 -1
  38. package/dist/storage/repo-manager.d.ts +18 -0
  39. package/package.json +1 -1
  40. package/skills/gitnexus-guide.md +11 -0
@@ -36,6 +36,30 @@ const TYPE_CONTEXT_TYPES = new Set([
36
36
  'type_predicate_annotation',
37
37
  'asserts_annotation',
38
38
  ]);
39
+ /**
40
+ * Wrappers that don't change which VALUE flows through them (#2083 M3 U1) —
41
+ * unwrapped when resolving call-result attribution (`const b = (await
42
+ * escape(t))!` still attaches `resultDefs: [b]` to the escape site) and
43
+ * member-chain roots. Distinct from {@link TsHarvester.unwrapLvalue}, which is
44
+ * the narrower LVALUE set.
45
+ */
46
+ const VALUE_WRAPPER_TYPES = new Set([
47
+ 'parenthesized_expression',
48
+ 'non_null_expression',
49
+ 'as_expression',
50
+ 'satisfies_expression',
51
+ 'await_expression',
52
+ ]);
53
+ /** Literal text of a `string` node (concatenated fragments; raw escapes kept). */
54
+ const stringLiteralText = (node) => {
55
+ let out = '';
56
+ for (let i = 0; i < node.namedChildCount; i++) {
57
+ const c = node.namedChild(i);
58
+ if (c?.type === 'string_fragment' || c?.type === 'escape_sequence')
59
+ out += c.text;
60
+ }
61
+ return out;
62
+ };
39
63
  export class TsHarvester {
40
64
  fnNode;
41
65
  bindings = [];
@@ -60,6 +84,16 @@ export class TsHarvester {
60
84
  * here falsely kills the prior def on the not-taken path).
61
85
  */
62
86
  conditionalDepth = 0;
87
+ /**
88
+ * Call/new node id → bindings whose declarator/assignment VALUE is exactly
89
+ * that call (#2083 M3 U1). Registered by the declarator/assignment handlers
90
+ * BEFORE the value walk, consumed by {@link visitCall} when it reaches the
91
+ * node — the indirection keeps result-def attribution per-declarator
92
+ * (`const a = t, b = escape(t)` attaches `[b]` to the escape site only) and
93
+ * top-level-only (`const c = cond ? escape(b) : b` attaches nothing — the
94
+ * bypass occurrence must keep `c` taintable, plan KTD4a).
95
+ */
96
+ resultDefTargets = new Map();
63
97
  constructor(fnNode) {
64
98
  this.fnNode = fnNode;
65
99
  this.fnId = fnNode.id;
@@ -397,7 +431,10 @@ export class TsHarvester {
397
431
  // live def (`x = source(); var x; sink(x)` must keep source→sink;
398
432
  // tri-review P2). `let`/`const` declarators genuinely initialize.
399
433
  if (name && (value || t === 'lexical_declaration')) {
434
+ const snap = acc.defSnapshot();
400
435
  this.walkDefPattern(name, acc);
436
+ if (value)
437
+ this.registerResultDefs(value, acc.defsSince(snap));
401
438
  }
402
439
  if (value)
403
440
  this.walkValue(value, acc);
@@ -406,8 +443,12 @@ export class TsHarvester {
406
443
  case 'assignment_expression': {
407
444
  const left = node.childForFieldName('left');
408
445
  const right = node.childForFieldName('right');
409
- if (left)
446
+ if (left) {
447
+ const snap = acc.defSnapshot();
410
448
  this.walkDefPattern(this.unwrapLvalue(left), acc);
449
+ if (right)
450
+ this.registerResultDefs(right, acc.defsSince(snap));
451
+ }
411
452
  if (right)
412
453
  this.walkValue(right, acc);
413
454
  return;
@@ -508,6 +549,42 @@ export class TsHarvester {
508
549
  this.walkValue(body, acc);
509
550
  return;
510
551
  }
552
+ case 'call_expression':
553
+ // #2083 M3 U1: explicit case (previously default-descended) — same
554
+ // uses, plus a taint-site record. MUST keep defs/uses byte-identical.
555
+ this.visitCall(node, acc, 'call');
556
+ return;
557
+ case 'new_expression':
558
+ this.visitCall(node, acc, 'new');
559
+ return;
560
+ case 'member_expression':
561
+ case 'subscript_expression':
562
+ // #2083 M3 U1: value-position member chain — same uses as the old
563
+ // default descent (root identifier + dynamic subscript indices), plus
564
+ // a member-read site for the innermost identifier-rooted access.
565
+ this.walkChain(node, acc, false);
566
+ return;
567
+ case 'sequence_expression': {
568
+ // Comma operator: only the LAST operand's value flows. Earlier operands
569
+ // are evaluated for side effects — record their uses but suppress
570
+ // occurrence fan-out so `exec((log(x), 'safe'))` does not taint exec's
571
+ // arg 0 with `x` (review fix). Defs/uses stay byte-identical to the old
572
+ // default descent; only the sites layer narrows.
573
+ const operands = [];
574
+ for (let i = 0; i < node.namedChildCount; i++) {
575
+ const c = node.namedChild(i);
576
+ if (c)
577
+ operands.push(c);
578
+ }
579
+ const last = operands.length - 1;
580
+ operands.forEach((op, i) => {
581
+ if (i === last)
582
+ this.walkValue(op, acc);
583
+ else
584
+ acc.suppressOccurrences(() => this.walkValue(op, acc));
585
+ });
586
+ return;
587
+ }
511
588
  default:
512
589
  for (let i = 0; i < node.namedChildCount; i++) {
513
590
  const c = node.namedChild(i);
@@ -554,8 +631,11 @@ export class TsHarvester {
554
631
  case 'member_expression':
555
632
  case 'subscript_expression':
556
633
  // Property/element write — NOT a scalar def (KTD4); its identifiers
557
- // (object, computed key) are uses.
558
- this.walkValue(node, acc);
634
+ // (object, computed key) are uses. WRITE position (#2083 M3 U1): the
635
+ // written access itself is not a value read — no member-read site for
636
+ // it (`obj.p = q` records nothing; `req.body.x = v`'s mid-chain LOAD
637
+ // of `req.body` still does).
638
+ this.walkChain(node, acc, true);
559
639
  return;
560
640
  default:
561
641
  for (let i = 0; i < node.namedChildCount; i++) {
@@ -565,6 +645,180 @@ export class TsHarvester {
565
645
  }
566
646
  }
567
647
  }
648
+ // ── taint-site harvest (#2083 M3 U1) ────────────────────────────────────
649
+ /** Strip value-transparent wrappers (`(x)`, `x!`, `x as T`, `await x`). */
650
+ unwrapValueWrappers(node) {
651
+ let n = node;
652
+ while (VALUE_WRAPPER_TYPES.has(n.type)) {
653
+ const inner = n.namedChild(0);
654
+ if (!inner)
655
+ break;
656
+ n = inner;
657
+ }
658
+ return n;
659
+ }
660
+ /**
661
+ * When `value`'s root (after unwrapping) is a call/new node, remember that
662
+ * its site should carry `resultDefs: defs` — consumed by {@link visitCall}
663
+ * once the value walk reaches the node.
664
+ */
665
+ registerResultDefs(value, defs) {
666
+ if (defs.length === 0)
667
+ return;
668
+ const root = this.unwrapValueWrappers(value);
669
+ if (root.type === 'call_expression' || root.type === 'new_expression') {
670
+ this.resultDefTargets.set(root.id, [...defs]);
671
+ }
672
+ }
673
+ /**
674
+ * Explicit call/new handler: records a call site (callee path, receiver,
675
+ * per-arg occurrence entries, spread/template markers, require literal,
676
+ * result defs) while reproducing EXACTLY the uses the old default descent
677
+ * recorded — callee chain root + dynamic subscript indices + arguments.
678
+ */
679
+ visitCall(node, acc, kind) {
680
+ const calleeNode = node.childForFieldName(kind === 'new' ? 'constructor' : 'function');
681
+ const argsNode = node.childForFieldName('arguments');
682
+ const siteIdx = acc.openCallSite(kind);
683
+ acc.pushFrame(siteIdx);
684
+ let calleePath;
685
+ if (calleeNode) {
686
+ const callee = this.unwrapValueWrappers(calleeNode);
687
+ if (callee.type === 'identifier') {
688
+ // The callee NAME is a statement-level use but NOT a value occurrence
689
+ // flowing into any enclosing argument — `exec(escape(x))` must not
690
+ // put the `escape` binding itself into exec's arg 0 (only x, tagged
691
+ // via the escape site). Receiver-chain roots DO fan out (KTD5 TITO).
692
+ acc.addUseWithoutOccurrence(this.resolve(callee));
693
+ calleePath = callee.text;
694
+ }
695
+ else if (callee.type === 'member_expression' || callee.type === 'subscript_expression') {
696
+ // skipFinalRead: the final access IS the callee, carried by the
697
+ // dotted path — recording it as a member read would double-count.
698
+ // Mid-chain reads (`req.body` inside `req.body.toString()`) ARE
699
+ // recorded (plan KTD2).
700
+ const chain = this.walkChain(callee, acc, true);
701
+ calleePath = chain.path;
702
+ if (chain.rootIdx !== undefined)
703
+ acc.setSiteReceiver(siteIdx, chain.rootIdx);
704
+ }
705
+ else {
706
+ // Call-rooted chains, IIFEs, function expressions — no dotted path;
707
+ // the walk still records uses and nested sites.
708
+ this.walkValue(callee, acc);
709
+ }
710
+ if (calleePath !== undefined)
711
+ acc.setSiteCallee(siteIdx, calleePath);
712
+ }
713
+ const resultDefs = this.resultDefTargets.get(node.id);
714
+ if (resultDefs !== undefined)
715
+ acc.setSiteResultDefs(siteIdx, resultDefs);
716
+ if (argsNode?.type === 'template_string') {
717
+ // Tagged template (`sql\`…${id}\``): the `arguments` field is a
718
+ // template_string, not an arguments node — substitution occurrences
719
+ // aggregate at position 0 and the site is marked non-positional.
720
+ acc.setSiteTemplate(siteIdx);
721
+ acc.setFrameArg(0);
722
+ this.walkValue(argsNode, acc);
723
+ }
724
+ else if (argsNode) {
725
+ let pos = 0;
726
+ for (let i = 0; i < argsNode.namedChildCount; i++) {
727
+ const arg = argsNode.namedChild(i);
728
+ if (!arg || arg.type === 'comment')
729
+ continue;
730
+ acc.setFrameArg(pos);
731
+ if (arg.type === 'spread_element') {
732
+ acc.setSiteSpread(siteIdx, pos);
733
+ const inner = arg.namedChild(0);
734
+ if (inner)
735
+ this.walkValue(inner, acc);
736
+ }
737
+ else {
738
+ if (kind === 'call' && pos === 0 && calleePath === 'require' && arg.type === 'string') {
739
+ // CommonJS `require('lit')` — record the literal so the matcher
740
+ // resolves require'd aliases like ESM imports (plan KTD7).
741
+ acc.setSiteRequireArg(siteIdx, stringLiteralText(arg));
742
+ }
743
+ this.walkValue(arg, acc);
744
+ }
745
+ pos++;
746
+ }
747
+ }
748
+ acc.popFrame();
749
+ }
750
+ /**
751
+ * Member/subscript chain walk shared by value position, write position, and
752
+ * callee position. Use-recording is identical to the old default descent
753
+ * (chain-root identifier once, dynamic subscript index expressions, full
754
+ * walk of non-identifier roots) — NO double-recording. Member-read sites:
755
+ * at most ONE per chain — the INNERMOST access — and only when the chain
756
+ * root is an identifier and the access's key is static (`.prop` or a
757
+ * string-literal subscript); `skipFinalRead` suppresses it when that access
758
+ * is the final one (callee / write target). Optional chaining (`?.`) never
759
+ * appears in the output (field-based traversal normalizes it); dynamic
760
+ * computed keys record nothing (documented KTD10 FN).
761
+ */
762
+ walkChain(node, acc, skipFinalRead) {
763
+ // Collect accesses outer→inner (unshift), then resolve the root.
764
+ const accesses = [];
765
+ let cur = this.unwrapValueWrappers(node);
766
+ for (;;) {
767
+ if (cur.type === 'member_expression') {
768
+ const prop = cur.childForFieldName('property');
769
+ accesses.unshift({ prop: prop?.text });
770
+ const obj = cur.childForFieldName('object');
771
+ if (!obj)
772
+ break;
773
+ cur = this.unwrapValueWrappers(obj);
774
+ }
775
+ else if (cur.type === 'subscript_expression') {
776
+ const index = cur.childForFieldName('index');
777
+ if (index?.type === 'string') {
778
+ accesses.unshift({ prop: stringLiteralText(index) });
779
+ }
780
+ else {
781
+ accesses.unshift({ dynamicIndex: index ?? undefined });
782
+ }
783
+ const obj = cur.childForFieldName('object');
784
+ if (!obj)
785
+ break;
786
+ cur = this.unwrapValueWrappers(obj);
787
+ }
788
+ else {
789
+ break;
790
+ }
791
+ }
792
+ let rootIdx;
793
+ let rootSegment;
794
+ if (cur.type === 'identifier') {
795
+ rootIdx = this.resolve(cur);
796
+ acc.addUse(rootIdx);
797
+ rootSegment = cur.text;
798
+ }
799
+ else if (cur.type === 'this' || cur.type === 'super') {
800
+ rootSegment = cur.text; // path segment only — `this`/`super` never bind
801
+ }
802
+ else {
803
+ this.walkValue(cur, acc); // call-rooted etc. — uses + nested sites
804
+ }
805
+ // Dynamic subscript index expressions are real value reads (old default
806
+ // descent walked them) — inner→outer matches the old recording order.
807
+ for (const a of accesses) {
808
+ if (a.dynamicIndex)
809
+ this.walkValue(a.dynamicIndex, acc);
810
+ }
811
+ const innermost = accesses[0];
812
+ if (rootIdx !== undefined &&
813
+ innermost?.prop !== undefined &&
814
+ !(skipFinalRead && accesses.length === 1)) {
815
+ acc.addMemberRead(rootIdx, innermost.prop);
816
+ }
817
+ const path = rootSegment !== undefined && accesses.every((a) => a.prop !== undefined)
818
+ ? [rootSegment, ...accesses.map((a) => a.prop)].join('.')
819
+ : undefined;
820
+ return { path, rootIdx };
821
+ }
568
822
  }
569
823
  /** Ordered, deduplicating def/use collector for one statement record. */
570
824
  class FactAccumulator {
@@ -575,6 +829,13 @@ class FactAccumulator {
575
829
  defSeen = new Set();
576
830
  useSeen = new Set();
577
831
  mayDefSeen = new Set();
832
+ /** Taint sites recorded for this statement (#2083 M3 U1). */
833
+ sites = [];
834
+ /** Composite (object|property|parent) keys of recorded member-read sites, so
835
+ * dedup is O(1) instead of a rescan of `sites` per read. */
836
+ memberReadKeys = new Set();
837
+ /** Stack of open call/new sites — the occurrence fan-out targets. */
838
+ frames = [];
578
839
  constructor(line) {
579
840
  this.line = line;
580
841
  }
@@ -592,6 +853,16 @@ class FactAccumulator {
592
853
  this.mayDefs.push(idx);
593
854
  }
594
855
  addUse(idx) {
856
+ // Occurrence fan-out happens BEFORE the statement-level dedup: `exec(x, x)`
857
+ // records x at BOTH arg positions even though `uses` lists it once.
858
+ this.recordOccurrence(idx);
859
+ this.addUseWithoutOccurrence(idx);
860
+ }
861
+ /**
862
+ * Statement-level use that is NOT a value occurrence in any open site
863
+ * argument — bare callee names only (#2083 M3 U1, see visitCall).
864
+ */
865
+ addUseWithoutOccurrence(idx) {
595
866
  if (this.useSeen.has(idx))
596
867
  return;
597
868
  this.useSeen.add(idx);
@@ -603,6 +874,134 @@ class FactAccumulator {
603
874
  useCount() {
604
875
  return this.uses.length;
605
876
  }
877
+ // ── site machinery (#2083 M3 U1) ─────────────────────────────────────────
878
+ /** `[defs.length, mayDefs.length]` marker for {@link defsSince}. */
879
+ defSnapshot() {
880
+ return [this.defs.length, this.mayDefs.length];
881
+ }
882
+ /** Binding indices def'd (must- OR may-) since the snapshot was taken. */
883
+ defsSince(snap) {
884
+ return [...this.defs.slice(snap[0]), ...this.mayDefs.slice(snap[1])];
885
+ }
886
+ /** Open a call/new site; parent = innermost enclosing argument position. */
887
+ openCallSite(kind) {
888
+ const site = { kind };
889
+ const parent = this.innermostArgPosition();
890
+ if (parent)
891
+ site.parent = parent;
892
+ this.sites.push(site);
893
+ return this.sites.length - 1;
894
+ }
895
+ pushFrame(siteIdx) {
896
+ this.frames.push({ siteIdx, argIdx: -1 });
897
+ }
898
+ popFrame() {
899
+ this.frames.pop();
900
+ }
901
+ /** Set the argument position the top frame is currently walking. */
902
+ setFrameArg(argIdx) {
903
+ const top = this.frames[this.frames.length - 1];
904
+ if (top)
905
+ top.argIdx = argIdx;
906
+ }
907
+ /**
908
+ * Run `fn` with all open arg frames temporarily detached (argIdx = -1), so
909
+ * identifier reads inside still record USES but do NOT fan occurrences into
910
+ * the enclosing sink-argument position. Used for the non-value operands of a
911
+ * sequence (comma) expression — only the final operand's value flows.
912
+ */
913
+ suppressOccurrences(fn) {
914
+ const saved = this.frames.map((f) => f.argIdx);
915
+ for (const f of this.frames)
916
+ f.argIdx = -1;
917
+ try {
918
+ fn();
919
+ }
920
+ finally {
921
+ this.frames.forEach((f, i) => {
922
+ f.argIdx = saved[i];
923
+ });
924
+ }
925
+ }
926
+ setSiteCallee(siteIdx, callee) {
927
+ this.sites[siteIdx].callee = callee;
928
+ }
929
+ setSiteReceiver(siteIdx, receiver) {
930
+ this.sites[siteIdx].receiver = receiver;
931
+ }
932
+ setSiteResultDefs(siteIdx, resultDefs) {
933
+ this.sites[siteIdx].resultDefs = [...resultDefs];
934
+ }
935
+ setSiteSpread(siteIdx, firstSpreadArg) {
936
+ const site = this.sites[siteIdx];
937
+ if (site.spread === undefined)
938
+ site.spread = firstSpreadArg;
939
+ }
940
+ setSiteTemplate(siteIdx) {
941
+ this.sites[siteIdx].template = true;
942
+ }
943
+ setSiteRequireArg(siteIdx, literal) {
944
+ this.sites[siteIdx].requireArg = literal;
945
+ }
946
+ /**
947
+ * Record a value-position member read. Exact duplicates within the
948
+ * statement (same object/property/parent position) dedup; reads at
949
+ * DIFFERENT argument positions stay distinct (`exec(req.body, req.body)`
950
+ * is two occurrences — KTD6 finding identity needs both).
951
+ */
952
+ addMemberRead(object, property) {
953
+ const parent = this.innermostArgPosition();
954
+ const dedupKey = `${object}|${property}|${parent ? `${parent[0]}:${parent[1]}` : 'top'}`;
955
+ if (this.memberReadKeys.has(dedupKey))
956
+ return;
957
+ this.memberReadKeys.add(dedupKey);
958
+ const site = { kind: 'member-read' };
959
+ if (parent)
960
+ site.parent = parent;
961
+ site.object = object;
962
+ site.property = property;
963
+ this.sites.push(site);
964
+ }
965
+ innermostArgPosition() {
966
+ for (let i = this.frames.length - 1; i >= 0; i--) {
967
+ const f = this.frames[i];
968
+ if (f.argIdx >= 0)
969
+ return [f.siteIdx, f.argIdx];
970
+ }
971
+ return undefined;
972
+ }
973
+ /**
974
+ * Fan a binding occurrence out to every arg-active open frame. The entry is
975
+ * via-tagged with the site of the IMMEDIATELY nested frame when one exists:
976
+ * `exec(escape(x))` puts a plain `x` in escape's arg 0 and `[x, escapeIdx]`
977
+ * in exec's arg 0 — the KTD4a interposition substrate.
978
+ */
979
+ recordOccurrence(idx) {
980
+ for (let i = this.frames.length - 1; i >= 0; i--) {
981
+ const f = this.frames[i];
982
+ if (f.argIdx < 0)
983
+ continue;
984
+ const via = i + 1 < this.frames.length ? this.frames[i + 1].siteIdx : undefined;
985
+ this.pushArgEntry(f.siteIdx, f.argIdx, idx, via);
986
+ }
987
+ }
988
+ pushArgEntry(siteIdx, argIdx, bindingIdx, via) {
989
+ const site = this.sites[siteIdx];
990
+ const args = (site.args ??= []);
991
+ while (args.length <= argIdx)
992
+ args.push([]);
993
+ const list = args[argIdx];
994
+ // Dedup exact (binding, via) pairs per position — `f(x + x)` is one entry;
995
+ // `f(x + g(x))` keeps the plain AND the via-tagged entry (distinct paths).
996
+ for (const e of list) {
997
+ const match = typeof e === 'number'
998
+ ? via === undefined && e === bindingIdx
999
+ : via !== undefined && e[0] === bindingIdx && e[1] === via;
1000
+ if (match)
1001
+ return;
1002
+ }
1003
+ list.push(via === undefined ? bindingIdx : [bindingIdx, via]);
1004
+ }
606
1005
  finish() {
607
1006
  return {
608
1007
  line: this.line,
@@ -611,6 +1010,23 @@ class FactAccumulator {
611
1010
  // Optional field stays absent when empty — keeps the serialized
612
1011
  // side-channel payload lean (most statements have no may-defs).
613
1012
  ...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
1013
+ // Sites likewise omit-when-empty (#2083 M3 U1): flag-off runs never
1014
+ // harvest, and most fact-bearing statements carry no calls.
1015
+ ...(this.sites.length > 0 ? { sites: this.sites.map(finalizeSite) } : {}),
614
1016
  };
615
1017
  }
616
1018
  }
1019
+ /** Trim trailing empty arg positions; drop `args` entirely when all-empty. */
1020
+ const finalizeSite = (site) => {
1021
+ const args = site.args;
1022
+ if (args !== undefined) {
1023
+ let end = args.length;
1024
+ while (end > 0 && args[end - 1].length === 0)
1025
+ end--;
1026
+ if (end === 0)
1027
+ delete site.args;
1028
+ else if (end < args.length)
1029
+ site.args = args.slice(0, end);
1030
+ }
1031
+ return site;
1032
+ };
@@ -57,6 +57,22 @@ export interface PipelineOptions {
57
57
  * programmatic / server path only, like the M1 caps.
58
58
  */
59
59
  pdgMaxReachingDefEdgesPerFunction?: number;
60
+ /**
61
+ * Per-function taint findings cap for the scope-resolution taint pass
62
+ * (#2083 M3). `undefined` ⇒ `DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION`
63
+ * (200); `0` ⇒ no cap (unlimited). Emit-time-only — NOT folded into the
64
+ * parse-cache chunk key; recorded resolved in `RepoMeta.pdg` so a cap
65
+ * change forces a full writeback. No CLI flag or rc key (KTD8) —
66
+ * programmatic / server path only, like the other pdg caps.
67
+ */
68
+ pdgMaxTaintFindingsPerFunction?: number;
69
+ /**
70
+ * Per-finding taint hop cap (#2083 M3, KTD6 — bounds the persisted
71
+ * hop-encoded `reason`). `undefined` ⇒ `DEFAULT_PDG_MAX_TAINT_HOPS` (32);
72
+ * `0` ⇒ no cap (unlimited). Same emit-time-only / RepoMeta-stamped /
73
+ * no-CLI-flag discipline as `pdgMaxTaintFindingsPerFunction`.
74
+ */
75
+ pdgMaxTaintHops?: number;
60
76
  /**
61
77
  * Request parsing with the worker pool disabled. The sequential parser was
62
78
  * removed — the worker pool is the sole parse path — so setting this now
@@ -289,6 +289,8 @@ export const scopeResolutionPhase = {
289
289
  pdg: ctx.options?.pdg === true,
290
290
  pdgMaxEdgesPerFunction: ctx.options?.pdgMaxEdgesPerFunction,
291
291
  pdgMaxReachingDefEdgesPerFunction: ctx.options?.pdgMaxReachingDefEdgesPerFunction,
292
+ pdgMaxTaintFindingsPerFunction: ctx.options?.pdgMaxTaintFindingsPerFunction,
293
+ pdgMaxTaintHops: ctx.options?.pdgMaxTaintHops,
292
294
  recordResolutionOutcome: (outcome) => {
293
295
  resolutionOutcomes.push(outcome);
294
296
  },
@@ -71,6 +71,14 @@ interface RunScopeResolutionInput {
71
71
  /** Per-function REACHING_DEF edge cap (#2082 M2). `undefined` ⇒
72
72
  * {@link DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION}; `0` ⇒ no cap. */
73
73
  readonly pdgMaxReachingDefEdgesPerFunction?: number;
74
+ /** Per-function taint findings cap (#2083 M3, consumed by the U4 taint
75
+ * emit step in the pdg window). `undefined` ⇒
76
+ * `DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION` (200); `0` ⇒ no cap. */
77
+ readonly pdgMaxTaintFindingsPerFunction?: number;
78
+ /** Per-finding taint hop cap (#2083 M3 KTD6 — bounds the hop-encoded
79
+ * `reason`; consumed by the U4 taint emit step). `undefined` ⇒
80
+ * `DEFAULT_PDG_MAX_TAINT_HOPS` (32); `0` ⇒ no cap. */
81
+ readonly pdgMaxTaintHops?: number;
74
82
  /**
75
83
  * Optional graph-node lookup built ONCE by the caller and shared across
76
84
  * every language pass. `buildGraphNodeLookup` scans the whole graph and is