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.
- package/dist/cli/ai-context.js +1 -0
- package/dist/cli/skill-gen.js +1 -0
- package/dist/core/ingestion/cfg/emit.d.ts +16 -1
- package/dist/core/ingestion/cfg/emit.js +10 -3
- package/dist/core/ingestion/cfg/reaching-defs.d.ts +7 -0
- package/dist/core/ingestion/cfg/reaching-defs.js +9 -0
- package/dist/core/ingestion/cfg/types.d.ts +91 -0
- package/dist/core/ingestion/cfg/visitors/typescript-harvest.d.ts +38 -0
- package/dist/core/ingestion/cfg/visitors/typescript-harvest.js +419 -3
- package/dist/core/ingestion/pipeline.d.ts +16 -0
- package/dist/core/ingestion/scope-resolution/pipeline/phase.js +2 -0
- package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +8 -0
- package/dist/core/ingestion/scope-resolution/pipeline/run.js +119 -3
- package/dist/core/ingestion/taint/emit.d.ts +124 -0
- package/dist/core/ingestion/taint/emit.js +204 -0
- package/dist/core/ingestion/taint/match.d.ts +153 -0
- package/dist/core/ingestion/taint/match.js +278 -0
- package/dist/core/ingestion/taint/path-codec.d.ts +134 -0
- package/dist/core/ingestion/taint/path-codec.js +190 -0
- package/dist/core/ingestion/taint/propagate.d.ts +216 -0
- package/dist/core/ingestion/taint/propagate.js +664 -0
- package/dist/core/ingestion/taint/site-safety.d.ts +29 -0
- package/dist/core/ingestion/taint/site-safety.js +98 -0
- package/dist/core/ingestion/taint/source-sink-config.d.ts +94 -23
- package/dist/core/ingestion/taint/source-sink-config.js +11 -11
- package/dist/core/ingestion/taint/source-sink-registry.d.ts +6 -4
- package/dist/core/ingestion/taint/source-sink-registry.js +6 -4
- package/dist/core/ingestion/taint/typescript-model.d.ts +38 -0
- package/dist/core/ingestion/taint/typescript-model.js +102 -0
- package/dist/core/run-analyze.d.ts +8 -1
- package/dist/core/run-analyze.js +14 -0
- package/dist/mcp/local/local-backend.d.ts +29 -0
- package/dist/mcp/local/local-backend.js +241 -1
- package/dist/mcp/resources.js +1 -0
- package/dist/mcp/tools.d.ts +9 -0
- package/dist/mcp/tools.js +53 -0
- package/dist/storage/parse-cache.js +10 -1
- package/dist/storage/repo-manager.d.ts +18 -0
- package/package.json +1 -1
- 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
|
-
|
|
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
|