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
@@ -30,7 +30,10 @@ import { extractParsedFile } from '../../scope-extractor-bridge.js';
30
30
  import { finalizeScopeModel } from '../../finalize-orchestrator.js';
31
31
  import { resolveReferenceSites } from '../../resolve-references.js';
32
32
  import { buildGraphNodeLookup } from '../graph-bridge/node-lookup.js';
33
- import { emitFileCfgs, emitFileReachingDefs, isEmitSafeCfg, DEFAULT_MAX_CFG_EDGES_PER_FUNCTION, DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION, } from '../../cfg/emit.js';
33
+ import { emitFileCfgs, emitFileReachingDefs, isEmitSafeCfg, DEFAULT_MAX_CFG_EDGES_PER_FUNCTION, DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION, REACHING_DEF_FACTS_PER_EDGE_CAP, } from '../../cfg/emit.js';
34
+ import { emitFileTaint, DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION, DEFAULT_PDG_MAX_TAINT_HOPS, } from '../../taint/emit.js';
35
+ import { registerBuiltinTaintModels } from '../../taint/typescript-model.js';
36
+ import { getSourceSinkConfig } from '../../taint/source-sink-registry.js';
34
37
  import { resolveDefGraphId } from '../graph-bridge/ids.js';
35
38
  import { buildPopulatedMethodDispatch } from '../graph-bridge/method-dispatch.js';
36
39
  import { propagateImportedReturnTypes } from '../passes/imported-return-types.js';
@@ -468,6 +471,11 @@ export function runScopeResolution(input, provider) {
468
471
  // pair can't bracket them; without this accumulator the M2 cost would
469
472
  // silently disappear into `emit=` and field regressions would be invisible.
470
473
  let pdgMs = 0;
474
+ // M3 (#2083 U4): accumulated taint time (match + taint-side solve +
475
+ // propagate + TAINTED/SANITIZES emit), a sibling of `pdgMs` for the same
476
+ // reason — it interleaves per file inside `emit=`, so only an accumulator
477
+ // can bracket it. Printed as the PROF `taint=` segment.
478
+ let taintMs = 0;
471
479
  if (input.pdg === true) {
472
480
  let cfgBlocks = 0;
473
481
  let cfgEdges = 0;
@@ -476,6 +484,45 @@ export function runScopeResolution(input, provider) {
476
484
  let rdDropped = 0;
477
485
  let rdFacts = 0;
478
486
  let rdTruncated = 0;
487
+ // ── M3 taint setup (#2083 U4) ────────────────────────────────────────
488
+ // Explicit model-registration seam (idempotent, cheap) — the registry
489
+ // stays empty on non-pdg runs, preserving default-run parity. The
490
+ // registry is keyed by `SupportedLanguages` enum VALUES ('typescript' /
491
+ // 'javascript'), and `ScopeResolver.language` IS a `SupportedLanguages`
492
+ // member registered under those same constants — the join is direct
493
+ // equality, no mapping table. A language without a registered spec
494
+ // (python, go, …) skips taint entirely: no work, no warn spam (KTD8).
495
+ registerBuiltinTaintModels();
496
+ const taintSpec = getSourceSinkConfig(provider.language);
497
+ // Taint-side solver fact cap: the SAME derivation emitFileReachingDefs
498
+ // uses for the RD projection (edge cap × headroom factor, 0 ⇒ unlimited),
499
+ // so taint coverage and RD coverage truncate together — a function is
500
+ // never a taint coverage gap while its RD projection computed, and the
501
+ // RD layer's per-function truncation warn already names it.
502
+ const rdEdgeCap = input.pdgMaxReachingDefEdgesPerFunction ?? DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION;
503
+ const taintLimits = {
504
+ maxFindingsPerFunction: input.pdgMaxTaintFindingsPerFunction ?? DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
505
+ maxHops: input.pdgMaxTaintHops ?? DEFAULT_PDG_MAX_TAINT_HOPS,
506
+ maxFacts: rdEdgeCap > 0 ? rdEdgeCap * REACHING_DEF_FACTS_PER_EDGE_CAP : 0,
507
+ };
508
+ // Cross-file aggregate of EVERY TaintEmitResult counter (the M2 emit
509
+ // result shipped with two fields dropped on the floor — R4 forbids that
510
+ // here; gaps/drops feed the unconditional warn below, volume feeds the
511
+ // per-language debug line).
512
+ const taintTotals = {
513
+ analyzed: 0,
514
+ noMatch: 0,
515
+ unsafeSites: 0,
516
+ gapTruncated: 0,
517
+ gapOverflow: 0,
518
+ gapNoFacts: 0,
519
+ findings: 0,
520
+ kills: 0,
521
+ dropped: 0,
522
+ hopsTruncated: 0,
523
+ gapExamples: [],
524
+ dropExamples: [],
525
+ };
479
526
  for (const pf of emitParsedFiles) {
480
527
  const cfgs = pf.cfgSideChannel;
481
528
  // Defensive: cfgSideChannel is opaque (`unknown`) and crosses the cache /
@@ -521,6 +568,34 @@ export function runScopeResolution(input, provider) {
521
568
  rdDropped += rd.droppedEdges;
522
569
  rdFacts += rd.facts;
523
570
  rdTruncated += rd.truncatedFunctions;
571
+ // M3 (#2083 U4): taint over the SAME validated CFGs, inside the SAME
572
+ // per-file try (a taint throw costs this file's taint layer only —
573
+ // its CFG/REACHING_DEF edges above are already in the graph). Skipped
574
+ // entirely when the language has no registered model.
575
+ if (taintSpec !== undefined) {
576
+ const t1 = PROF ? performance.now() : 0;
577
+ const taint = emitFileTaint(graph, wellFormed, pf.parsedImports, taintSpec, taintLimits, (message) => logger.warn(message));
578
+ if (PROF)
579
+ taintMs += performance.now() - t1;
580
+ taintTotals.analyzed += taint.functionsAnalyzed;
581
+ taintTotals.noMatch += taint.functionsSkippedNoMatch;
582
+ taintTotals.unsafeSites += taint.functionsSkippedUnsafeSites;
583
+ taintTotals.gapTruncated += taint.functionsCoverageGap.truncated;
584
+ taintTotals.gapOverflow += taint.functionsCoverageGap.overflow;
585
+ taintTotals.gapNoFacts += taint.functionsCoverageGap['no-facts'];
586
+ taintTotals.findings += taint.findingsEmitted;
587
+ taintTotals.kills += taint.killsEmitted;
588
+ taintTotals.dropped += taint.findingsDropped;
589
+ taintTotals.hopsTruncated += taint.hopsTruncatedFindings;
590
+ for (const ex of taint.coverageGapExamples) {
591
+ if (taintTotals.gapExamples.length < 5)
592
+ taintTotals.gapExamples.push(ex);
593
+ }
594
+ for (const ex of taint.droppedExamples) {
595
+ if (taintTotals.dropExamples.length < 5)
596
+ taintTotals.dropExamples.push(ex);
597
+ }
598
+ }
524
599
  }
525
600
  catch (err) {
526
601
  // Last-resort isolation, mirroring the worker-side per-file try/catch:
@@ -540,7 +615,47 @@ export function runScopeResolution(input, provider) {
540
615
  (cfgDroppedEdges > 0 ? `, ${cfgDroppedEdges} edges dropped (per-function cap)` : '') +
541
616
  `; ${rdEdges} REACHING_DEF edges (${rdFacts} facts)` +
542
617
  (rdDropped > 0 ? `, ${rdDropped} REACHING_DEF edges dropped (per-function cap)` : '') +
543
- (rdTruncated > 0 ? `, ${rdTruncated} function(s) hit the fact limit` : ''));
618
+ (rdTruncated > 0 ? `, ${rdTruncated} function(s) hit the fact limit` : '') +
619
+ // M3 volume telemetry — only for languages with a registered model.
620
+ (taintSpec !== undefined
621
+ ? `; taint: ${taintTotals.findings} TAINTED, ${taintTotals.kills} SANITIZES ` +
622
+ `(${taintTotals.analyzed} function(s) analyzed, ` +
623
+ `${taintTotals.noMatch} skipped: no source/sink match` +
624
+ (taintTotals.hopsTruncated > 0
625
+ ? `, ${taintTotals.hopsTruncated} finding(s) with truncated hop paths`
626
+ : '') +
627
+ `)`
628
+ : ''));
629
+ }
630
+ // R4: taint coverage gaps and cap drops surface UNCONDITIONALLY (never
631
+ // logger.debug, never input.onWarn) at the per-language aggregate, with
632
+ // counts and up to 5 example functions. Per-function warns above cover
633
+ // the rare/actionable cases (unsafe sites, cap drops); solver-status gaps
634
+ // were already per-function-warned by the RD layer (same solver, same
635
+ // fact cap), so this aggregate is their single taint-side surface.
636
+ if (taintSpec !== undefined) {
637
+ const gapCount = taintTotals.unsafeSites +
638
+ taintTotals.gapTruncated +
639
+ taintTotals.gapOverflow +
640
+ taintTotals.gapNoFacts;
641
+ if (gapCount > 0 || taintTotals.dropped > 0) {
642
+ const parts = [];
643
+ if (gapCount > 0) {
644
+ parts.push(`${gapCount} function(s) skipped for taint ` +
645
+ `(${taintTotals.gapTruncated} fact-limit, ${taintTotals.gapOverflow} overflow, ` +
646
+ `${taintTotals.gapNoFacts} no-facts, ${taintTotals.unsafeSites} malformed sites)` +
647
+ (taintTotals.gapExamples.length > 0
648
+ ? ` — e.g. ${taintTotals.gapExamples.join(', ')}`
649
+ : ''));
650
+ }
651
+ if (taintTotals.dropped > 0) {
652
+ parts.push(`${taintTotals.dropped} finding(s) dropped by the per-function cap` +
653
+ (taintTotals.dropExamples.length > 0
654
+ ? ` — e.g. ${taintTotals.dropExamples.join(', ')}`
655
+ : ''));
656
+ }
657
+ logger.warn(`[taint] lang=${provider.language}: ${parts.join('; ')}`);
658
+ }
544
659
  }
545
660
  }
546
661
  if (PROF) {
@@ -552,7 +667,8 @@ export function runScopeResolution(input, provider) {
552
667
  ` resolve=${ns(tPropagate, tResolve).toFixed(0)}ms` +
553
668
  ` emit=${ns(tResolve, tEnd).toFixed(0)}ms` +
554
669
  // pdg ⊆ emit: the M2 reaching-defs share of the emit bucket (#2082 U4).
555
- (input.pdg === true ? ` pdg=${pdgMs.toFixed(0)}ms` : '') +
670
+ // taint emit likewise: the M3 match+solve+propagate+emit share (#2083 U4).
671
+ (input.pdg === true ? ` pdg=${pdgMs.toFixed(0)}ms taint=${taintMs.toFixed(0)}ms` : '') +
556
672
  ` total=${ns(tStart, tEnd).toFixed(0)}ms` +
557
673
  ` (${parsedFiles.length} files)`);
558
674
  }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * In-phase taint emission (#2083 M3 U4, plan KTD1/KTD6).
3
+ *
4
+ * Per-file driver for the M3 taint pass: gate → match → solve → propagate →
5
+ * persist sparse `TAINTED` + `SANITIZES` edges. Invoked from the pdg window in
6
+ * scope-resolution (`pipeline/run.ts`), immediately after `emitFileReachingDefs`
7
+ * inside the SAME per-file try — per-file isolation for free (KTD1). Mirrors
8
+ * `emitFileReachingDefs` (cfg/emit.ts) for the budget/dedup/warn discipline and
9
+ * the telemetry-result shape.
10
+ *
11
+ * ## Per-function pipeline (ordering is load-bearing)
12
+ *
13
+ * 1. `hasTaintSafeSites` — a corrupted-store site annotation degrades to
14
+ * SKIP-TAINT-KEEP-RD for this function (counted + warned), never a crash
15
+ * (KTD2; the matcher/propagator dereference indices unvalidated).
16
+ * 2. `matchFunctionSites` against the language spec (the import index is
17
+ * built ONCE per file — imports are a file-level fact).
18
+ * 3. ZERO-MATCH FAST PATH: the solver runs only when the function has at
19
+ * least one matched source AND one matched sink. In a typical repo almost
20
+ * no function has both; an unconditional second `computeReachingDefs` per
21
+ * function would ship a near-2× solve cost to every `--pdg` user.
22
+ * 4. `computeReachingDefs` with the taint `maxFacts` — by DEFAULT the M2
23
+ * derived `DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION` (deliberate
24
+ * reuse, not a new constant: the fact-materialization envelope is a
25
+ * memory question, O(defs×uses), orthogonal to the findings cap, and M2
26
+ * already validated exactly this envelope on the same solver in the same
27
+ * window). The run.ts caller derives `limits.maxFacts` from the SAME
28
+ * RD-edge-cap formula `emitFileReachingDefs` uses, so taint coverage and
29
+ * RD coverage truncate together — a function is never `truncated` for one
30
+ * layer and `computed` for the other.
31
+ * 5. `computeTaintFlows` — a non-`computed` status is a per-function
32
+ * COVERAGE GAP (R4: counted by `gapReason`, function skipped entirely,
33
+ * never partially analyzed).
34
+ * 6. Emit one `TAINTED` edge per finding and one `SANITIZES` edge per kill.
35
+ * Kills are emitted even when findings are zero — a fully-sanitized
36
+ * function's kills are exactly its evidence of safety.
37
+ *
38
+ * ## Identity, dedup, budget (KTD6)
39
+ *
40
+ * Findings carry STATEMENT-LEVEL identity — function anchor + sink kind +
41
+ * source occurrence (point/site/object-binding/property) + sink occurrence
42
+ * (point/site/arg/binding) — NOT the REACHING_DEF block-level key (block-pair
43
+ * conflation would drop `exec(req.body, req.query)`'s second finding). The
44
+ * propagation engine dedups by this exact key BEFORE its deterministic cap
45
+ * (`maxFindingsPerFunction`) and counts the overflow; this module templates
46
+ * the same coordinates into the edge id (binding identity via the shared
47
+ * `bindingKey`; the free-text `property` rides LAST so it can never collide
48
+ * into another component) and warns with the drop count on truncation.
49
+ *
50
+ * `reason` carries the versioned hop encoding (`taint/path-codec.ts` — U6's
51
+ * `explain` decodes the same module) for `TAINTED`, and the killed binding's
52
+ * plain name for `SANITIZES` (M0/S1 queryability verdict, like REACHING_DEF).
53
+ *
54
+ * ## Warn split (R4 vs noise)
55
+ *
56
+ * Unsafe-site skips and cap drops warn PER FUNCTION here (rare, actionable —
57
+ * mirrors `emitFileReachingDefs`' malformed/cap warns). Solver coverage gaps
58
+ * (`truncated`/`overflow`) do NOT re-warn per function: the RD layer already
59
+ * warned for the same function with the same solver status (same `maxFacts`
60
+ * derivation — see step 4), and a duplicate `[taint]` line per mega-function
61
+ * would be pure spam. They are counted (+ exampled) in the result and the
62
+ * run.ts caller aggregates them into ONE unconditional `logger.warn` per
63
+ * language (R4) — never dropped on the floor (the M2 lesson).
64
+ */
65
+ import type { ParsedImport } from '../../../_shared/index.js';
66
+ import type { KnowledgeGraph } from '../../graph/types.js';
67
+ import type { FunctionCfg } from '../cfg/types.js';
68
+ export { DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION, DEFAULT_PDG_MAX_TAINT_HOPS, } from './propagate.js';
69
+ import type { SourceSinkSanitizerSpec } from './source-sink-config.js';
70
+ export interface TaintEmitLimits {
71
+ /** Per-function findings cap (post-dedup). `undefined` ⇒
72
+ * {@link DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION}; `0` ⇒ unlimited. */
73
+ readonly maxFindingsPerFunction?: number;
74
+ /** Per-finding hop cap (source-side prefix kept). `undefined` ⇒
75
+ * {@link DEFAULT_PDG_MAX_TAINT_HOPS}; `0` ⇒ unlimited. */
76
+ readonly maxHops?: number;
77
+ /**
78
+ * Solver fact-materialization cap for the taint-side `computeReachingDefs`
79
+ * call. `undefined` ⇒ {@link DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION}
80
+ * (the M2 derived default — see the module doc for why it is REUSED rather
81
+ * than derived from the findings cap); `0` ⇒ unlimited.
82
+ */
83
+ readonly maxFacts?: number;
84
+ }
85
+ /**
86
+ * Full taint-emit telemetry for one file. EVERY counter is surfaced by the
87
+ * run.ts aggregate (the M2 emit result had two fields dropped on the floor —
88
+ * the plan names that mistake; don't repeat it).
89
+ */
90
+ export interface TaintEmitResult {
91
+ /** Functions fully propagated (`computeTaintFlows` returned `computed`). */
92
+ functionsAnalyzed: number;
93
+ /** Functions skipped by the zero-match fast path (no solver call). */
94
+ functionsSkippedNoMatch: number;
95
+ /** Functions whose `sites` failed {@link hasTaintSafeSites} (skip-taint-keep-RD). */
96
+ functionsSkippedUnsafeSites: number;
97
+ /** Source+sink functions skipped on a non-`computed` solver status (R4). */
98
+ functionsCoverageGap: {
99
+ truncated: number;
100
+ overflow: number;
101
+ 'no-facts': number;
102
+ };
103
+ /** TAINTED edges persisted. */
104
+ findingsEmitted: number;
105
+ /** SANITIZES edges persisted (emitted even when findings are zero). */
106
+ killsEmitted: number;
107
+ /** Findings dropped by the per-function cap (post-dedup), summed. */
108
+ findingsDropped: number;
109
+ /** Findings whose persisted hop path is a truncated prefix (hop/byte cap). */
110
+ hopsTruncatedFindings: number;
111
+ /** ≤{@link MAX_EXAMPLES} `file:line` anchors of gap/unsafe-site functions. */
112
+ coverageGapExamples: string[];
113
+ /** ≤{@link MAX_EXAMPLES} `file:line` anchors of cap-dropped functions. */
114
+ droppedExamples: string[];
115
+ }
116
+ /**
117
+ * Run the taint pass over one file's emit-safe CFGs and persist TAINTED +
118
+ * SANITIZES edges. `cfgs` MUST already be `isEmitSafeCfg`-filtered (the same
119
+ * `wellFormed` array the caller fed `emitFileCfgs`/`emitFileReachingDefs`) —
120
+ * block/edge anchors are trusted here; only the M3 `sites` layer is
121
+ * re-validated (`hasTaintSafeSites`). Never throws on well-formed input;
122
+ * the caller's per-file try isolates the rest.
123
+ */
124
+ export declare function emitFileTaint(graph: KnowledgeGraph, cfgs: readonly FunctionCfg[], parsedImports: readonly ParsedImport[], spec: SourceSinkSanitizerSpec, limits?: TaintEmitLimits, onWarn?: (message: string) => void): TaintEmitResult;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * In-phase taint emission (#2083 M3 U4, plan KTD1/KTD6).
3
+ *
4
+ * Per-file driver for the M3 taint pass: gate → match → solve → propagate →
5
+ * persist sparse `TAINTED` + `SANITIZES` edges. Invoked from the pdg window in
6
+ * scope-resolution (`pipeline/run.ts`), immediately after `emitFileReachingDefs`
7
+ * inside the SAME per-file try — per-file isolation for free (KTD1). Mirrors
8
+ * `emitFileReachingDefs` (cfg/emit.ts) for the budget/dedup/warn discipline and
9
+ * the telemetry-result shape.
10
+ *
11
+ * ## Per-function pipeline (ordering is load-bearing)
12
+ *
13
+ * 1. `hasTaintSafeSites` — a corrupted-store site annotation degrades to
14
+ * SKIP-TAINT-KEEP-RD for this function (counted + warned), never a crash
15
+ * (KTD2; the matcher/propagator dereference indices unvalidated).
16
+ * 2. `matchFunctionSites` against the language spec (the import index is
17
+ * built ONCE per file — imports are a file-level fact).
18
+ * 3. ZERO-MATCH FAST PATH: the solver runs only when the function has at
19
+ * least one matched source AND one matched sink. In a typical repo almost
20
+ * no function has both; an unconditional second `computeReachingDefs` per
21
+ * function would ship a near-2× solve cost to every `--pdg` user.
22
+ * 4. `computeReachingDefs` with the taint `maxFacts` — by DEFAULT the M2
23
+ * derived `DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION` (deliberate
24
+ * reuse, not a new constant: the fact-materialization envelope is a
25
+ * memory question, O(defs×uses), orthogonal to the findings cap, and M2
26
+ * already validated exactly this envelope on the same solver in the same
27
+ * window). The run.ts caller derives `limits.maxFacts` from the SAME
28
+ * RD-edge-cap formula `emitFileReachingDefs` uses, so taint coverage and
29
+ * RD coverage truncate together — a function is never `truncated` for one
30
+ * layer and `computed` for the other.
31
+ * 5. `computeTaintFlows` — a non-`computed` status is a per-function
32
+ * COVERAGE GAP (R4: counted by `gapReason`, function skipped entirely,
33
+ * never partially analyzed).
34
+ * 6. Emit one `TAINTED` edge per finding and one `SANITIZES` edge per kill.
35
+ * Kills are emitted even when findings are zero — a fully-sanitized
36
+ * function's kills are exactly its evidence of safety.
37
+ *
38
+ * ## Identity, dedup, budget (KTD6)
39
+ *
40
+ * Findings carry STATEMENT-LEVEL identity — function anchor + sink kind +
41
+ * source occurrence (point/site/object-binding/property) + sink occurrence
42
+ * (point/site/arg/binding) — NOT the REACHING_DEF block-level key (block-pair
43
+ * conflation would drop `exec(req.body, req.query)`'s second finding). The
44
+ * propagation engine dedups by this exact key BEFORE its deterministic cap
45
+ * (`maxFindingsPerFunction`) and counts the overflow; this module templates
46
+ * the same coordinates into the edge id (binding identity via the shared
47
+ * `bindingKey`; the free-text `property` rides LAST so it can never collide
48
+ * into another component) and warns with the drop count on truncation.
49
+ *
50
+ * `reason` carries the versioned hop encoding (`taint/path-codec.ts` — U6's
51
+ * `explain` decodes the same module) for `TAINTED`, and the killed binding's
52
+ * plain name for `SANITIZES` (M0/S1 queryability verdict, like REACHING_DEF).
53
+ *
54
+ * ## Warn split (R4 vs noise)
55
+ *
56
+ * Unsafe-site skips and cap drops warn PER FUNCTION here (rare, actionable —
57
+ * mirrors `emitFileReachingDefs`' malformed/cap warns). Solver coverage gaps
58
+ * (`truncated`/`overflow`) do NOT re-warn per function: the RD layer already
59
+ * warned for the same function with the same solver status (same `maxFacts`
60
+ * derivation — see step 4), and a duplicate `[taint]` line per mega-function
61
+ * would be pure spam. They are counted (+ exampled) in the result and the
62
+ * run.ts caller aggregates them into ONE unconditional `logger.warn` per
63
+ * language (R4) — never dropped on the floor (the M2 lesson).
64
+ */
65
+ import { generateId } from '../../../lib/utils.js';
66
+ import { basicBlockId, bindingKey, DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION, } from '../cfg/emit.js';
67
+ import { computeReachingDefs, pointKey } from '../cfg/reaching-defs.js';
68
+ import { hasTaintSafeSites } from './site-safety.js';
69
+ import { buildTaintImportIndex, matchFunctionSites } from './match.js';
70
+ import { computeTaintFlows, DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION, DEFAULT_PDG_MAX_TAINT_HOPS, } from './propagate.js';
71
+ // Re-exported so the pipeline (run.ts) sources the taint default caps through
72
+ // this orchestration module rather than reaching into propagate.ts directly.
73
+ export { DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION, DEFAULT_PDG_MAX_TAINT_HOPS, } from './propagate.js';
74
+ import { encodeTaintPath } from './path-codec.js';
75
+ /** Cap on example anchors carried per result (aggregate-warn material, R4). */
76
+ const MAX_EXAMPLES = 5;
77
+ const pushExample = (list, anchor) => {
78
+ if (list.length < MAX_EXAMPLES)
79
+ list.push(anchor);
80
+ };
81
+ /**
82
+ * Run the taint pass over one file's emit-safe CFGs and persist TAINTED +
83
+ * SANITIZES edges. `cfgs` MUST already be `isEmitSafeCfg`-filtered (the same
84
+ * `wellFormed` array the caller fed `emitFileCfgs`/`emitFileReachingDefs`) —
85
+ * block/edge anchors are trusted here; only the M3 `sites` layer is
86
+ * re-validated (`hasTaintSafeSites`). Never throws on well-formed input;
87
+ * the caller's per-file try isolates the rest.
88
+ */
89
+ export function emitFileTaint(graph, cfgs, parsedImports, spec, limits, onWarn) {
90
+ const result = {
91
+ functionsAnalyzed: 0,
92
+ functionsSkippedNoMatch: 0,
93
+ functionsSkippedUnsafeSites: 0,
94
+ functionsCoverageGap: { truncated: 0, overflow: 0, 'no-facts': 0 },
95
+ findingsEmitted: 0,
96
+ killsEmitted: 0,
97
+ findingsDropped: 0,
98
+ hopsTruncatedFindings: 0,
99
+ coverageGapExamples: [],
100
+ droppedExamples: [],
101
+ };
102
+ // Imports are a FILE-level fact — build the index once, not per function.
103
+ const importIndex = buildTaintImportIndex(parsedImports);
104
+ const maxFindingsPerFunction = limits?.maxFindingsPerFunction ?? DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION;
105
+ const maxHops = limits?.maxHops ?? DEFAULT_PDG_MAX_TAINT_HOPS;
106
+ const maxFacts = limits?.maxFacts ?? DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION;
107
+ // Defensive cross-CFG id guard: finding identity is unique WITHIN a
108
+ // function by construction (the propagation engine dedups), so a repeat can
109
+ // only mean two CFGs sharing an anchor — skip, never double-insert.
110
+ const seenEdgeIds = new Set();
111
+ for (const cfg of cfgs) {
112
+ const { filePath, functionStartLine, functionStartColumn } = cfg;
113
+ const anchor = `${filePath}:${functionStartLine}`;
114
+ if (!hasTaintSafeSites(cfg)) {
115
+ result.functionsSkippedUnsafeSites++;
116
+ pushExample(result.coverageGapExamples, anchor);
117
+ onWarn?.(`[taint] ${anchor}: malformed site annotations (out-of-range binding/site ` +
118
+ `indices) — taint skipped for this function; its CFG and REACHING_DEF ` +
119
+ `layers are unaffected`);
120
+ continue;
121
+ }
122
+ const matches = matchFunctionSites(cfg, spec, importIndex);
123
+ if (!matches.hasSource || !matches.hasSink) {
124
+ // Zero-match fast path: no solver call (see module doc step 3).
125
+ result.functionsSkippedNoMatch++;
126
+ continue;
127
+ }
128
+ const defUse = computeReachingDefs(cfg, { maxFacts });
129
+ const flows = computeTaintFlows(cfg, defUse, matches, { maxFindingsPerFunction, maxHops });
130
+ if (flows.status === 'coverage-gap') {
131
+ // R4: skipped entirely, counted by reason; aggregate-warned by the
132
+ // caller (the RD layer already per-function-warned this solver status).
133
+ result.functionsCoverageGap[flows.gapReason ?? 'no-facts']++;
134
+ pushExample(result.coverageGapExamples, anchor);
135
+ continue;
136
+ }
137
+ result.functionsAnalyzed++;
138
+ const bindings = cfg.bindings ?? [];
139
+ const fnAnchor = `${filePath}:${functionStartLine}:${functionStartColumn}`;
140
+ const blockId = (p) => basicBlockId(filePath, functionStartLine, functionStartColumn, p.blockIndex);
141
+ const bKey = (idx) => {
142
+ const b = bindings[idx];
143
+ return b === undefined ? `#${idx}` : bindingKey(b);
144
+ };
145
+ // SANITIZES — one edge per kill, REGARDLESS of findings (kills can and do
146
+ // exist with zero findings: a fully-sanitized flow IS the kill evidence).
147
+ for (const kill of flows.kills) {
148
+ const id = generateId('SANITIZES', `${fnAnchor}:${pointKey(kill.sanitizer)}->${pointKey(kill.killedDef)}:` +
149
+ bKey(kill.bindingIdx));
150
+ if (seenEdgeIds.has(id))
151
+ continue;
152
+ seenEdgeIds.add(id);
153
+ graph.addRelationship({
154
+ id,
155
+ type: 'SANITIZES',
156
+ sourceId: blockId(kill.sanitizer),
157
+ targetId: blockId(kill.killedDef),
158
+ confidence: 1.0,
159
+ reason: bindings[kill.bindingIdx]?.name ?? `#${kill.bindingIdx}`,
160
+ });
161
+ result.killsEmitted++;
162
+ }
163
+ // TAINTED — one edge per finding (already deduped + capped upstream).
164
+ for (const finding of flows.findings) {
165
+ const { source, sink } = finding;
166
+ // KTD6 statement-level identity: function anchor + kind + source
167
+ // occurrence + sink occurrence + binding keys. The rule-(b) occurrence
168
+ // coordinates (site index / arg index) distinguish
169
+ // `exec(req.body, req.query)`'s two findings; `property` is free-text
170
+ // (string-literal subscripts) and rides LAST so it cannot collide into
171
+ // another component.
172
+ const id = generateId('TAINTED', `${fnAnchor}:${finding.sinkKind}:` +
173
+ `${pointKey(source.point)}.${source.siteIndex}:${bKey(source.objectBindingIdx)}:` +
174
+ `${pointKey(sink.point)}.${sink.siteIndex}.${sink.argIndex}:${bKey(sink.bindingIdx)}:` +
175
+ `${sink.entryName}:${source.property}`);
176
+ if (seenEdgeIds.has(id))
177
+ continue;
178
+ seenEdgeIds.add(id);
179
+ // `kind` rides the reason's `;<kind>` header — the only persisted
180
+ // channel for the finding's category (the edge id embedding it is not a
181
+ // stored column; `step` is INT32). U6's `explain` decodes it back.
182
+ const encoded = encodeTaintPath(finding.hops.map((h) => ({ name: h.name, line: h.point.line, viaCall: h.viaCall })), { truncated: finding.hopsTruncated === true, kind: finding.sinkKind });
183
+ if (encoded.truncated)
184
+ result.hopsTruncatedFindings++;
185
+ graph.addRelationship({
186
+ id,
187
+ type: 'TAINTED',
188
+ sourceId: blockId(source.point),
189
+ targetId: blockId(sink.point),
190
+ confidence: 1.0,
191
+ reason: encoded.reason,
192
+ });
193
+ result.findingsEmitted++;
194
+ }
195
+ if (flows.droppedFindings > 0) {
196
+ result.findingsDropped += flows.droppedFindings;
197
+ pushExample(result.droppedExamples, anchor);
198
+ onWarn?.(`[taint] ${anchor}: per-function taint findings cap ` +
199
+ `(${maxFindingsPerFunction}) reached — dropped ${flows.droppedFindings} of ` +
200
+ `${flows.findings.length + flows.droppedFindings} deduped findings`);
201
+ }
202
+ }
203
+ return result;
204
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Import-aware taint-site matcher (#2083 M3 U2, plan KTD7).
3
+ *
4
+ * Classifies a function's harvested {@link SiteRecord}s against a registered
5
+ * {@link SourceSinkSanitizerSpec}: which member reads are SOURCES, which
6
+ * call/new sites are SINKS (and at which argument positions), and which are
7
+ * SANITIZERS. Pure main-thread data work — sites + bindings come from the U1
8
+ * worker harvest, imports from `ParsedFile.parsedImports`; no AST, no I/O.
9
+ *
10
+ * PRECONDITION: the caller must gate the CFG through `hasTaintSafeSites`
11
+ * (taint/site-safety.ts) first — this module dereferences binding/site
12
+ * indices without re-validating them.
13
+ *
14
+ * ## Callee resolution precedence (bare and member-rooted calls)
15
+ *
16
+ * 1. ESM import join — the callee root's local name is resolved through the
17
+ * {@link TaintImportIndex} built from `parsedImports` (`named`/`alias`
18
+ * members, `namespace`/default-import module handles); `import { exec as
19
+ * run } from 'child_process'` makes `run(c)` resolve to
20
+ * `child_process.exec`, and `import * as cp …` makes `cp.exec(c)` resolve
21
+ * the same way.
22
+ * 2. require-literal join — a binding whose in-function defining site carries
23
+ * `requireArg` resolves like a namespace handle (`const cp =
24
+ * require('child_process'); cp.exec(c)`). A BARE call of a require-joined
25
+ * binding is matched under BOTH interpretations, `<module>.default` (the
26
+ * module/default export invoked directly) and `<module>.<localName>`
27
+ * (non-renamed destructured require — the harvest attaches `resultDefs`
28
+ * to destructured bindings without recording the property path, and the
29
+ * binding name IS the member name in the non-renamed case).
30
+ * 3. Bare-name fallback — TRUE GLOBALS only (`global: true` entries: `eval`,
31
+ * `new Function`, `encodeURIComponent`), and only when the name is neither
32
+ * import-bound nor shadowed. Conventional receiver names (`req`/`request`
33
+ * member-read sources, `res.send`, `.query`/`.execute`) are matched
34
+ * name-based by their own mechanisms, never via the global fallback.
35
+ *
36
+ * ## Shadowing rule (exact)
37
+ *
38
+ * A name is treated as function-local — blocking import/global resolution —
39
+ * iff the function's binding table contains a NON-`synthetic` entry with that
40
+ * name (an in-function `function exec(){}` / `const exec = …`). Synthetic
41
+ * bindings (kind `module`, `synthetic: true`) are imports, true globals, or
42
+ * enclosing-scope captures and do not shadow. Member-call roots use the
43
+ * harvested `receiver` binding index directly (no name scan).
44
+ *
45
+ * ## Documented resolution gaps (direction stated, per plan KTD10)
46
+ *
47
+ * - MODULE-LEVEL `const cp = require('child_process')`: the binding is
48
+ * synthetic inside the function, produces no `ParsedImport`, and its
49
+ * defining site lives outside the function's harvested sites — the
50
+ * require join cannot see it. Module-mechanism sinks miss (FN) and
51
+ * sanitizers don't kill (FP noise — never a false kill, the safe
52
+ * direction). Only in-function requires resolve.
53
+ * - RENAMED destructured require (`const { exec: run } = require(…)`):
54
+ * the dual interpretation resolves `run` to `child_process.run` — no
55
+ * match (FN). Non-renamed destructures resolve exactly.
56
+ * - CONSERVATIVE shadow scan for bare calls: ANY non-synthetic binding of
57
+ * the callee name anywhere in the function blocks import/global
58
+ * resolution, even when the shadow is block-scoped elsewhere and the call
59
+ * site actually sees the import (FN; rare; safe for sanitizers).
60
+ * - MODULE-LEVEL user declarations are indistinguishable from imports in
61
+ * the binding table (both synthetic). ESM forbids a module-level
62
+ * declaration colliding with an import name, so the import join is
63
+ * authoritative when an import exists; a module-level user function
64
+ * shadowing a TRUE GLOBAL (e.g. a local `encodeURIComponent`) is not
65
+ * detectable and would still match (pathological; accepted).
66
+ * - Handle COPIES (`const c2 = cp; c2.exec(…)`) are not followed — joins
67
+ * are one level deep (binding → import/require), never through
68
+ * assignments (FN).
69
+ * - `this.`/`super.`-rooted and call-rooted callee chains have no
70
+ * resolvable root: only the syntactic `anyReceiver`/`receivers`
71
+ * mechanisms can match them.
72
+ * - `reexport`/`wildcard`/`dynamic-*`/`side-effect` imports introduce no
73
+ * matcher-visible local binding and are skipped by the index.
74
+ */
75
+ import type { ParsedImport } from '../../../_shared/index.js';
76
+ import type { FunctionCfg } from '../cfg/types.js';
77
+ import type { SourceSinkSanitizerSpec, TaintMemberSourceEntry, TaintSanitizerEntry, TaintSinkEntry } from './source-sink-config.js';
78
+ /** What a local name imported into the file denotes. */
79
+ export interface TaintImportBinding {
80
+ /** Normalized module specifier (`node:` scheme stripped). */
81
+ readonly module: string;
82
+ /**
83
+ * Exported member bound by a named/aliased import; `undefined` when the
84
+ * local name is a MODULE HANDLE (namespace import, or a default import —
85
+ * CJS interop makes the default export ≈ the module object).
86
+ */
87
+ readonly member?: string;
88
+ }
89
+ /** Local name → import provenance for one file. Build once per file (U4). */
90
+ export type TaintImportIndex = ReadonlyMap<string, TaintImportBinding>;
91
+ /** A member-read site matched as a taint source. */
92
+ export interface MatchedSourceRead {
93
+ /** Index into the owning statement's `sites` array. */
94
+ readonly siteIndex: number;
95
+ readonly entry: TaintMemberSourceEntry;
96
+ }
97
+ /** A call/new site matched as a sink. */
98
+ export interface MatchedSinkCall {
99
+ /** Index into the owning statement's `sites` array. */
100
+ readonly siteIndex: number;
101
+ readonly entry: TaintSinkEntry;
102
+ /**
103
+ * Positions (indices into `site.args`) that are registered sink positions
104
+ * AND carry at least one recorded binding occurrence, after the spread
105
+ * rule (a recorded position ≥ `site.spread` matches when any registered
106
+ * position ≥ the spread index exists — runtime positions after a spread
107
+ * are unknowable) and the template rule (`template: true` aggregates all
108
+ * substitutions at position 0 and matches any-position). Never empty — a
109
+ * sink whose dangerous positions carry no occurrences cannot produce a
110
+ * finding and is not reported.
111
+ */
112
+ readonly argPositions: readonly number[];
113
+ }
114
+ /** A call site matched as a sanitizer (import-aware/global only — see module doc). */
115
+ export interface MatchedSanitizerCall {
116
+ /** Index into the owning statement's `sites` array. */
117
+ readonly siteIndex: number;
118
+ readonly entry: TaintSanitizerEntry;
119
+ /**
120
+ * Bindings the sanitizer's result defines directly (`const b = escape(t)`
121
+ * ⇒ `b`) — U3's kill targets (KTD4b). Empty for value-position sanitizer
122
+ * calls (`exec(escape(x))`), whose effect is occurrence INTERPOSITION via
123
+ * the site's `parent`/via-tag chain, not a def kill.
124
+ */
125
+ readonly resultDefs: readonly number[];
126
+ }
127
+ /** All matches within one statement. Emitted only when at least one list is non-empty. */
128
+ export interface StatementMatches {
129
+ readonly blockIndex: number;
130
+ readonly statementIndex: number;
131
+ readonly line: number;
132
+ readonly sources: readonly MatchedSourceRead[];
133
+ readonly sinks: readonly MatchedSinkCall[];
134
+ readonly sanitizers: readonly MatchedSanitizerCall[];
135
+ }
136
+ /** Classified sites for one function, in (block, statement, site, entry) order. */
137
+ export interface FunctionSiteMatches {
138
+ readonly statements: readonly StatementMatches[];
139
+ /** Fast-path gates for U4: the solver runs only when both are true. */
140
+ readonly hasSource: boolean;
141
+ readonly hasSink: boolean;
142
+ }
143
+ /**
144
+ * Build the local-name → module/member index from a file's `parsedImports`.
145
+ * Only `named`/`alias`/`namespace` kinds bind matcher-visible local names;
146
+ * `importedName === 'default'` collapses to a module handle.
147
+ */
148
+ export declare function buildTaintImportIndex(imports: readonly ParsedImport[]): TaintImportIndex;
149
+ /**
150
+ * Classify a function's harvested sites against a language spec. See the
151
+ * module doc for resolution precedence, the shadowing rule, and gaps.
152
+ */
153
+ export declare function matchFunctionSites(cfg: FunctionCfg, spec: SourceSinkSanitizerSpec, imports: TaintImportIndex): FunctionSiteMatches;