gitnexus 1.6.8-rc.21 → 1.6.8-rc.23

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 (37) hide show
  1. package/dist/core/incremental/subgraph-extract.js +12 -1
  2. package/dist/core/ingestion/pipeline-phases/index.d.ts +1 -0
  3. package/dist/core/ingestion/pipeline-phases/index.js +1 -0
  4. package/dist/core/ingestion/pipeline-phases/taint-summaries.d.ts +30 -0
  5. package/dist/core/ingestion/pipeline-phases/taint-summaries.js +81 -0
  6. package/dist/core/ingestion/pipeline.d.ts +13 -0
  7. package/dist/core/ingestion/pipeline.js +5 -1
  8. package/dist/core/ingestion/scope-resolution/pipeline/phase.d.ts +7 -0
  9. package/dist/core/ingestion/scope-resolution/pipeline/phase.js +15 -0
  10. package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +17 -0
  11. package/dist/core/ingestion/scope-resolution/pipeline/run.js +37 -1
  12. package/dist/core/ingestion/taint/interproc-emit.d.ts +50 -0
  13. package/dist/core/ingestion/taint/interproc-emit.js +88 -0
  14. package/dist/core/ingestion/taint/interproc-solver.d.ts +110 -0
  15. package/dist/core/ingestion/taint/interproc-solver.js +312 -0
  16. package/dist/core/ingestion/taint/propagate.d.ts +1 -1
  17. package/dist/core/ingestion/taint/propagate.js +4 -9
  18. package/dist/core/ingestion/taint/source-sink-config.d.ts +13 -0
  19. package/dist/core/ingestion/taint/source-sink-config.js +24 -1
  20. package/dist/core/ingestion/taint/summary-harvest-driver.d.ts +56 -0
  21. package/dist/core/ingestion/taint/summary-harvest-driver.js +118 -0
  22. package/dist/core/ingestion/taint/summary-harvest.d.ts +89 -0
  23. package/dist/core/ingestion/taint/summary-harvest.js +537 -0
  24. package/dist/core/ingestion/taint/summary-model.d.ts +200 -0
  25. package/dist/core/ingestion/taint/summary-model.js +86 -0
  26. package/dist/core/lbug/lbug-adapter.d.ts +18 -0
  27. package/dist/core/lbug/lbug-adapter.js +55 -0
  28. package/dist/core/run-analyze.d.ts +7 -1
  29. package/dist/core/run-analyze.js +21 -1
  30. package/dist/mcp/local/local-backend.js +93 -10
  31. package/dist/mcp/tools.js +7 -6
  32. package/dist/storage/repo-manager.d.ts +10 -0
  33. package/hooks/antigravity/gitnexus-antigravity-hook.cjs +89 -3
  34. package/hooks/claude/gitnexus-hook.cjs +88 -4
  35. package/hooks/claude/hook-db-lock-probe.cjs +53 -21
  36. package/package.json +1 -1
  37. package/skills/gitnexus-taint-analysis.md +178 -0
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Per-function taint SUMMARY model (#2084 M4 U2).
3
+ *
4
+ * A {@link FunctionSummary} is the compact, context-insensitive abstraction of
5
+ * one function's taint behaviour — the input to the interprocedural fixpoint
6
+ * (`interproc-solver.ts`). It is the GitNexus analogue of Pysa's `.pysa`
7
+ * models, Mariana Trench's "propagations", and CodeQL Models-as-Data summary
8
+ * rows: a function is reduced to how taint enters (params / generated sources),
9
+ * how it moves through (param→return, param→callee-arg), and where it lands
10
+ * (param→sink). The fixpoint composes these across resolved `CALLS` edges so a
11
+ * source in one function reaches a sink in another.
12
+ *
13
+ * ## Why summaries (not whole-program IFDS)
14
+ *
15
+ * The functional/summary method (Sharir-Pnueli 1981) analyses each function
16
+ * ONCE and propagates the result over the call graph — the same shape Pysa,
17
+ * Mariana Trench, and Infer use in production. GitNexus already resolves the
18
+ * call graph (`CALLS` edges carry final node ids), so the summary IS the only
19
+ * new artifact; propagation is graph reachability over a finite lattice.
20
+ *
21
+ * ## Granularity (first cut)
22
+ *
23
+ * WHOLE-PARAMETER. Ports are `param i`, `return`, and `receiver` — no field
24
+ * access paths (`arg0.field.sub`). Field sensitivity, callback-parameter ports
25
+ * (`Argument[0].Parameter[0]`), and context sensitivity are deferred (plan
26
+ * KTD; the largest JS/TS FN class — closures — stays a documented gap).
27
+ *
28
+ * ## Plain-data discipline
29
+ *
30
+ * A summary is a JSON-plain value type (no functions, class instances, Maps, or
31
+ * Symbols) so it survives `RunScopeResolutionStats` → `ScopeResolutionOutput`
32
+ * threading and any future worker/cache boundary unchanged — the same
33
+ * `Cloneable` constraint the CFG side channel obeys.
34
+ */
35
+ import type { SinkKind, SourceKind } from './source-sink-config.js';
36
+ /**
37
+ * Source-relative parameter index (0-based, in declaration order). A
38
+ * function's first parameter is `0`. Destructured / rest params map each bound
39
+ * name to the index of the formal parameter that introduced it (so
40
+ * `function f([a, b]) {}` binds both `a` and `b` to param `0`).
41
+ */
42
+ export type ParamIndex = number;
43
+ /**
44
+ * `param i` flows into argument `argIndex` of a call at source line `callLine`.
45
+ * The interprocedural solver joins this to the caller's outgoing `CALLS` edges
46
+ * by CALLEE NAME (`calleeName`) — NOT by `callLine` — because line-base parity
47
+ * between the CFG harvest (1-based) and the resolved reference site is fragile,
48
+ * while the callee identity is exact. It then applies the callee's summary at
49
+ * port `param argIndex`. This is the TITO ("taint-in-taint-out") propagation
50
+ * edge — a param laundered into a callee, the callee's behaviour deciding what
51
+ * happens next.
52
+ *
53
+ * `calleeName` is the site's dotted-callee tail (best-effort); absent when the
54
+ * callee chain was not statically resolvable, in which case the solver
55
+ * conservatively matches every outgoing call (sound over-approximation).
56
+ * `callLine` is the 1-based statement line as harvested (`StatementFacts.line`)
57
+ * — carried for hop display and as a TIE-BREAKER among several same-named
58
+ * callees of one caller, never as the primary join key.
59
+ */
60
+ export interface ParamToCallArg {
61
+ readonly param: ParamIndex;
62
+ readonly callLine: number;
63
+ readonly argIndex: number;
64
+ readonly calleeName?: string;
65
+ /**
66
+ * Sink kinds neutralised on EVERY harvested path from the param to this call
67
+ * argument (intersection-over-paths, #2084 review P1-2). A sanitizer between
68
+ * the param and the callee arg (`relay(x){ const y=escape(x); sinkFn(y); }`)
69
+ * must carry across the boundary so the callee's `paramToSink` of a
70
+ * neutralised kind does not fire (the cross-function false positive). Absent
71
+ * means none neutralised.
72
+ */
73
+ readonly neutralized?: readonly SinkKind[];
74
+ }
75
+ /**
76
+ * `param i` flows to the function's return value (a `return <expr>` use).
77
+ *
78
+ * RESERVED — not yet consumed by the fixpoint (#2084 review P1-1). The M3
79
+ * statement-level floor already treats every call as propagate-through, so it
80
+ * taints a callee's RESULT whenever the caller passes tainted input; param→
81
+ * return recall is therefore already covered, and consuming `paramToReturn`
82
+ * would only add PRECISION (avoiding the floor's over-approximation for
83
+ * functions that don't actually return their param) — a larger refactor
84
+ * deferred. Harvested + version-stamped so the precision pass can land without
85
+ * a cache-namespace bump.
86
+ */
87
+ export interface ParamToReturn {
88
+ readonly param: ParamIndex;
89
+ /** Sink kinds neutralised on EVERY path param→return (intersection). */
90
+ readonly neutralized?: readonly SinkKind[];
91
+ }
92
+ /** `param i` reaches a modelled sink of kind `sinkKind` inside this function. */
93
+ export interface ParamToSink {
94
+ readonly param: ParamIndex;
95
+ readonly sinkKind: SinkKind;
96
+ }
97
+ /**
98
+ * The function itself GENERATES a source (a modelled source read, e.g.
99
+ * `req.body`) that reaches its return value — calling it yields tainted data
100
+ * with no tainted input required. The generative analogue of Pysa's
101
+ * `TaintSource[...]` return model. CONSUMED by the fixpoint via the caller's
102
+ * {@link CallResult} edges (#2084 review P1-1): a caller that uses such a
103
+ * function's result composes this into a finding/propagation. This is the
104
+ * genuinely-additive recall the floor cannot cover (the source is inside the
105
+ * callee — the caller passes no tainted input for the floor to propagate).
106
+ */
107
+ export interface SourceToReturn {
108
+ readonly sourceKind: SourceKind;
109
+ }
110
+ /**
111
+ * What a user-function call's RESULT flows into, in the CALLER (#2084 review
112
+ * P1-1). Recorded when a call to a (potentially generative) user function has
113
+ * its return value used by the caller. The fixpoint composes it with the
114
+ * callee's {@link SourceToReturn}: if the callee returns a generated source,
115
+ * the caller's downstream use of the result is tainted.
116
+ */
117
+ export type CallResultDest = {
118
+ readonly to: 'sink';
119
+ readonly sinkKind: SinkKind;
120
+ } | {
121
+ readonly to: 'return';
122
+ } | {
123
+ readonly to: 'callArg';
124
+ readonly toCallee?: string;
125
+ readonly argIndex: ParamIndex;
126
+ };
127
+ /** The result of a call to `calleeName` flows to `dest` in this function. */
128
+ export interface CallResult {
129
+ readonly calleeName: string;
130
+ readonly dest: CallResultDest;
131
+ }
132
+ /**
133
+ * A modelled source generated in this function flows into argument `argIndex`
134
+ * of a call at `callLine`. This SEEDS the interprocedural fixpoint: the source
135
+ * taints the callee's parameter, which the callee's summary then carries to a
136
+ * sink (one or more hops away). The cross-function analogue of an intra-
137
+ * procedural `source → sink` partial flow whose sink lives in the callee.
138
+ */
139
+ export interface SourceToCallArg {
140
+ readonly sourceKind: SourceKind;
141
+ /** Carried for hop display + same-name tie-break; NOT the join key (see
142
+ * {@link ParamToCallArg} — the solver joins by `calleeName`). */
143
+ readonly callLine: number;
144
+ readonly argIndex: number;
145
+ readonly calleeName?: string;
146
+ /** Sink kinds neutralised on EVERY path from the generated source to this
147
+ * call argument (intersection; #2084 review P1-2 — see {@link ParamToCallArg}). */
148
+ readonly neutralized?: readonly SinkKind[];
149
+ }
150
+ /**
151
+ * The compact taint abstraction of one function. All arrays are deterministically
152
+ * sorted by the harvester and deduped, so two structurally-equal summaries
153
+ * serialise identically (the {@link summaryVersion} contract).
154
+ */
155
+ export interface FunctionSummary {
156
+ /** The resolved `Function`/`Method` graph node id this summary describes. */
157
+ readonly fnId: string;
158
+ /** Repo-relative source path (carried for diagnostics + the join debug). */
159
+ readonly filePath: string;
160
+ /** 1-based function start line (mirrors `FunctionCfg.functionStartLine`). */
161
+ readonly startLine: number;
162
+ /** Number of declared formal parameters (port arity). */
163
+ readonly paramCount: number;
164
+ /** param→return TITO edges. */
165
+ readonly paramToReturn: readonly ParamToReturn[];
166
+ /** param→callee-arg TITO edges (composed across `CALLS` in the fixpoint). */
167
+ readonly paramToCallArg: readonly ParamToCallArg[];
168
+ /** param→sink partial flows (a source reaching this param triggers a finding). */
169
+ readonly paramToSink: readonly ParamToSink[];
170
+ /** Generative source→return models. */
171
+ readonly sourceToReturn: readonly SourceToReturn[];
172
+ /** Generative source→callee-arg seeds (fixpoint entry points). */
173
+ readonly sourceToCallArg: readonly SourceToCallArg[];
174
+ /** Caller-side call-result flows — compose with callee `sourceToReturn`. */
175
+ readonly callResults: readonly CallResult[];
176
+ /**
177
+ * Content version stamp — `hash(own-facts ∪ sorted callee versions)`. The
178
+ * incremental cache key (Infer's content-keyed summary): equal across two
179
+ * runs iff the function's own taint facts AND every callee summary it depends
180
+ * on are unchanged. NOTE (#2084 review P1-1): callee-version composition is
181
+ * RESERVED — the harvester stamps the own-facts portion only
182
+ * ({@link ownFactsDigest}); the fixpoint does not yet recompose it.
183
+ */
184
+ readonly version: string;
185
+ }
186
+ /**
187
+ * Deterministic digest of a summary's OWN taint facts (everything except
188
+ * `version`, which is derived). Order-independent within each edge category —
189
+ * the harvester already sorts, but the digest re-canonicalises so a reordering
190
+ * never changes the stamp. Used as the leaf of {@link summaryVersion}.
191
+ */
192
+ export declare function ownFactsDigest(s: Pick<FunctionSummary, 'paramCount' | 'paramToReturn' | 'paramToCallArg' | 'paramToSink' | 'sourceToReturn' | 'sourceToCallArg' | 'callResults'>): string;
193
+ /**
194
+ * Content version stamp for a summary: `hash(ownFactsDigest ∪ sorted callee
195
+ * versions)`. Order-independent over callee versions (sorted). Equal iff the
196
+ * function's own facts AND every callee dependency are unchanged — this is the
197
+ * incremental invalidation primitive (a changed callee changes its version,
198
+ * which changes every transitive caller's version).
199
+ */
200
+ export declare function summaryVersion(ownDigest: string, calleeVersions: readonly string[]): string;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Per-function taint SUMMARY model (#2084 M4 U2).
3
+ *
4
+ * A {@link FunctionSummary} is the compact, context-insensitive abstraction of
5
+ * one function's taint behaviour — the input to the interprocedural fixpoint
6
+ * (`interproc-solver.ts`). It is the GitNexus analogue of Pysa's `.pysa`
7
+ * models, Mariana Trench's "propagations", and CodeQL Models-as-Data summary
8
+ * rows: a function is reduced to how taint enters (params / generated sources),
9
+ * how it moves through (param→return, param→callee-arg), and where it lands
10
+ * (param→sink). The fixpoint composes these across resolved `CALLS` edges so a
11
+ * source in one function reaches a sink in another.
12
+ *
13
+ * ## Why summaries (not whole-program IFDS)
14
+ *
15
+ * The functional/summary method (Sharir-Pnueli 1981) analyses each function
16
+ * ONCE and propagates the result over the call graph — the same shape Pysa,
17
+ * Mariana Trench, and Infer use in production. GitNexus already resolves the
18
+ * call graph (`CALLS` edges carry final node ids), so the summary IS the only
19
+ * new artifact; propagation is graph reachability over a finite lattice.
20
+ *
21
+ * ## Granularity (first cut)
22
+ *
23
+ * WHOLE-PARAMETER. Ports are `param i`, `return`, and `receiver` — no field
24
+ * access paths (`arg0.field.sub`). Field sensitivity, callback-parameter ports
25
+ * (`Argument[0].Parameter[0]`), and context sensitivity are deferred (plan
26
+ * KTD; the largest JS/TS FN class — closures — stays a documented gap).
27
+ *
28
+ * ## Plain-data discipline
29
+ *
30
+ * A summary is a JSON-plain value type (no functions, class instances, Maps, or
31
+ * Symbols) so it survives `RunScopeResolutionStats` → `ScopeResolutionOutput`
32
+ * threading and any future worker/cache boundary unchanged — the same
33
+ * `Cloneable` constraint the CFG side channel obeys.
34
+ */
35
+ /** Stable FNV-1a 32-bit hash → 8-char hex. Pure, deterministic, no deps. */
36
+ function fnv1a(input) {
37
+ let h = 0x811c9dc5;
38
+ for (let i = 0; i < input.length; i++) {
39
+ h ^= input.charCodeAt(i);
40
+ // 32-bit FNV prime multiply via shifts (avoids BigInt; stays in int32 land).
41
+ h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0;
42
+ }
43
+ return (h >>> 0).toString(16).padStart(8, '0');
44
+ }
45
+ /**
46
+ * Deterministic digest of a summary's OWN taint facts (everything except
47
+ * `version`, which is derived). Order-independent within each edge category —
48
+ * the harvester already sorts, but the digest re-canonicalises so a reordering
49
+ * never changes the stamp. Used as the leaf of {@link summaryVersion}.
50
+ */
51
+ export function ownFactsDigest(s) {
52
+ const parts = [`p${s.paramCount}`];
53
+ parts.push(...s.paramToReturn
54
+ .map((r) => `r:${r.param}:${[...(r.neutralized ?? [])].sort().join(',')}`)
55
+ .sort());
56
+ parts.push(...s.paramToCallArg
57
+ .map((c) => `c:${c.param}:${c.callLine}:${c.argIndex}:${c.calleeName ?? ''}:${[...(c.neutralized ?? [])].sort().join(',')}`)
58
+ .sort());
59
+ parts.push(...s.paramToSink.map((k) => `k:${k.param}:${k.sinkKind}`).sort());
60
+ parts.push(...s.sourceToReturn.map((g) => `g:${g.sourceKind}`).sort());
61
+ parts.push(...s.sourceToCallArg
62
+ .map((g) => `s:${g.sourceKind}:${g.callLine}:${g.argIndex}:${g.calleeName ?? ''}:${[...(g.neutralized ?? [])].sort().join(',')}`)
63
+ .sort());
64
+ parts.push(...s.callResults
65
+ .map((cr) => {
66
+ const d = cr.dest;
67
+ const dest = d.to === 'sink'
68
+ ? `sink:${d.sinkKind}`
69
+ : d.to === 'return'
70
+ ? 'return'
71
+ : `arg:${d.toCallee ?? ''}:${d.argIndex}`;
72
+ return `cr:${cr.calleeName}:${dest}`;
73
+ })
74
+ .sort());
75
+ return fnv1a(parts.join('|'));
76
+ }
77
+ /**
78
+ * Content version stamp for a summary: `hash(ownFactsDigest ∪ sorted callee
79
+ * versions)`. Order-independent over callee versions (sorted). Equal iff the
80
+ * function's own facts AND every callee dependency are unchanged — this is the
81
+ * incremental invalidation primitive (a changed callee changes its version,
82
+ * which changes every transitive caller's version).
83
+ */
84
+ export function summaryVersion(ownDigest, calleeVersions) {
85
+ return fnv1a(`${ownDigest}#${[...calleeVersions].sort().join(',')}`);
86
+ }
@@ -206,6 +206,24 @@ export declare const queryImporters: (targetFilePath: string) => Promise<string[
206
206
  export declare const deleteAllCommunitiesAndProcesses: () => Promise<{
207
207
  nodesDeleted: number;
208
208
  }>;
209
+ /**
210
+ * Drop every interprocedural `TAINT_PATH` relationship (#2084 M4 U6). Used at
211
+ * the start of an incremental `--pdg` writeback so the `taintSummaries` phase
212
+ * re-materialises them from scratch on the FULL recomputed graph.
213
+ *
214
+ * TAINT_PATH validity is a WHOLE-PROGRAM property (a flow A→C can be
215
+ * invalidated by a change to an INTERMEDIATE function whose file is neither A
216
+ * nor C). The endpoint-writability extract rule (`extractChangedSubgraph`)
217
+ * cannot see that — an A→C edge between two unchanged files would be skipped
218
+ * and a stale finding would survive. So, exactly like Community/Process, the
219
+ * sound move is delete-all-then-rebuild: cheap because TAINT_PATH is sparse
220
+ * (per-run capped), and the compute side already rebuilds every summary each
221
+ * run. Relationship-level (TAINT_PATH is an edge type, not a node label), so a
222
+ * plain DELETE on the typed CodeRelation rows — endpoints are untouched.
223
+ */
224
+ export declare const deleteAllInterprocTaintPaths: () => Promise<{
225
+ edgesDeleted: number;
226
+ }>;
209
227
  /**
210
228
  * Load the FTS extension on the supplied connection (or the singleton
211
229
  * writable connection when none is given).
@@ -1629,6 +1629,61 @@ export const deleteAllCommunitiesAndProcesses = async () => {
1629
1629
  }
1630
1630
  return { nodesDeleted };
1631
1631
  };
1632
+ /**
1633
+ * Drop every interprocedural `TAINT_PATH` relationship (#2084 M4 U6). Used at
1634
+ * the start of an incremental `--pdg` writeback so the `taintSummaries` phase
1635
+ * re-materialises them from scratch on the FULL recomputed graph.
1636
+ *
1637
+ * TAINT_PATH validity is a WHOLE-PROGRAM property (a flow A→C can be
1638
+ * invalidated by a change to an INTERMEDIATE function whose file is neither A
1639
+ * nor C). The endpoint-writability extract rule (`extractChangedSubgraph`)
1640
+ * cannot see that — an A→C edge between two unchanged files would be skipped
1641
+ * and a stale finding would survive. So, exactly like Community/Process, the
1642
+ * sound move is delete-all-then-rebuild: cheap because TAINT_PATH is sparse
1643
+ * (per-run capped), and the compute side already rebuilds every summary each
1644
+ * run. Relationship-level (TAINT_PATH is an edge type, not a node label), so a
1645
+ * plain DELETE on the typed CodeRelation rows — endpoints are untouched.
1646
+ */
1647
+ export const deleteAllInterprocTaintPaths = async () => {
1648
+ if (!conn) {
1649
+ throw new Error('LadybugDB not initialized. Call initLbug first.');
1650
+ }
1651
+ let edgesDeleted = 0;
1652
+ let countResult;
1653
+ try {
1654
+ countResult = await conn.query(`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'TAINT_PATH' RETURN count(r) AS cnt`);
1655
+ const result = Array.isArray(countResult) ? countResult[0] : countResult;
1656
+ const rows = await result.getAll();
1657
+ const count = Number(rows[0]?.cnt ?? rows[0]?.[0] ?? 0);
1658
+ if (count > 0) {
1659
+ await conn.query(`MATCH ()-[r:CodeRelation]->() WHERE r.type = 'TAINT_PATH' DELETE r`);
1660
+ edgesDeleted = count;
1661
+ }
1662
+ }
1663
+ catch (err) {
1664
+ // A missing table on a freshly-initialized DB is the benign, expected case
1665
+ // (the count query above is what throws) — stay silent. Any OTHER failure
1666
+ // (lock, disk, native error) would leave stale TAINT_PATH rows that the
1667
+ // subsequent re-extract then DUPLICATES (CodeRelation has no PK), so it
1668
+ // must ABORT the writeback (#2084 review P2-5): re-throw so the caller's
1669
+ // crash-recovery dirty flag forces a clean full rebuild on the next run,
1670
+ // rather than silently writing duplicate cross-function findings.
1671
+ const msg = err instanceof Error ? err.message : String(err);
1672
+ if (/no table|not exist|not found|does not exist|Table .* does not exist/i.test(msg)) {
1673
+ if (countResult)
1674
+ await closeQueryResults(countResult);
1675
+ return { edgesDeleted };
1676
+ }
1677
+ if (countResult)
1678
+ await closeQueryResults(countResult);
1679
+ throw new Error(`[taint-interproc] failed to clear existing TAINT_PATH edges before incremental ` +
1680
+ `re-write (${msg}) — aborting to avoid duplicate cross-function findings; ` +
1681
+ `the next run will full-rebuild`);
1682
+ }
1683
+ if (countResult)
1684
+ await closeQueryResults(countResult);
1685
+ return { edgesDeleted };
1686
+ };
1632
1687
  // ============================================================================
1633
1688
  // Full-Text Search (FTS) Functions
1634
1689
  // ============================================================================
@@ -72,6 +72,12 @@ export interface AnalyzeOptions {
72
72
  /** Per-finding taint hop cap (#2083 M3, KTD6). Forwarded to
73
73
  * `PipelineOptions.pdgMaxTaintHops`. No CLI flag or rc key (KTD8). */
74
74
  pdgMaxTaintHops?: number;
75
+ /** Per-run cross-function findings/hops/edges caps (#2084 review P1-3).
76
+ * Forwarded to the matching `PipelineOptions.pdgMaxInterproc*`; resolved
77
+ * into `RepoMeta.pdg`. No CLI flag or rc key (KTD8). */
78
+ pdgMaxInterprocFindings?: number;
79
+ pdgMaxInterprocHops?: number;
80
+ pdgMaxInterprocEdges?: number;
75
81
  /**
76
82
  * Default branch threaded into generated AGENTS.md / CLAUDE.md so the
77
83
  * regression-compare example uses the configured branch instead of a
@@ -192,7 +198,7 @@ export declare const collectBranchCacheKeys: (storagePath: string, excludeDir?:
192
198
  * defaults so an explicit-default run compares equal to a default run
193
199
  * (`0` = unlimited is preserved as `0`). Pure + exported for testing.
194
200
  */
195
- type PdgOptions = Pick<AnalyzeOptions, 'pdg' | 'pdgMaxFunctionLines' | 'pdgMaxEdgesPerFunction' | 'pdgMaxReachingDefEdgesPerFunction' | 'pdgMaxTaintFindingsPerFunction' | 'pdgMaxTaintHops'>;
201
+ type PdgOptions = Pick<AnalyzeOptions, 'pdg' | 'pdgMaxFunctionLines' | 'pdgMaxEdgesPerFunction' | 'pdgMaxReachingDefEdgesPerFunction' | 'pdgMaxTaintFindingsPerFunction' | 'pdgMaxTaintHops' | 'pdgMaxInterprocFindings' | 'pdgMaxInterprocHops' | 'pdgMaxInterprocEdges'>;
196
202
  export declare const resolvePdgConfig: (options: PdgOptions) => RepoMeta["pdg"];
197
203
  /**
198
204
  * Whether the requested `--pdg` configuration differs from the one the
@@ -13,7 +13,7 @@ import fs from 'fs/promises';
13
13
  import { execFileSync } from 'child_process';
14
14
  import { runPipelineFromRepo } from './ingestion/pipeline.js';
15
15
  import { resetDegradedParseCounter } from './tree-sitter/safe-parse.js';
16
- import { initLbug, loadGraphToLbug, getLbugStats, executeQuery, executeWithReusedStatement, closeLbug, loadCachedEmbeddings, deleteNodesForFile, deleteAllCommunitiesAndProcesses, queryImporters, loadFTSExtension, } from './lbug/lbug-adapter.js';
16
+ import { initLbug, loadGraphToLbug, getLbugStats, executeQuery, executeWithReusedStatement, closeLbug, loadCachedEmbeddings, deleteNodesForFile, deleteAllCommunitiesAndProcesses, deleteAllInterprocTaintPaths, queryImporters, loadFTSExtension, } from './lbug/lbug-adapter.js';
17
17
  import { createSearchFTSIndexes, verifySearchFTSIndexes } from './search/fts-indexes.js';
18
18
  import { resolveAnalyzeInstallPolicy } from './lbug/extension-loader.js';
19
19
  import { startWalCheckpointDriver, } from './lbug/wal-checkpoint-driver.js';
@@ -21,6 +21,8 @@ import { getStoragePaths, resolveBranchPlacement, saveMeta, loadMeta, ensureGitN
21
21
  import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js';
22
22
  import { DEFAULT_MAX_CFG_EDGES_PER_FUNCTION, DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION, } from './ingestion/cfg/emit.js';
23
23
  import { DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION, DEFAULT_PDG_MAX_TAINT_HOPS, } from './ingestion/taint/propagate.js';
24
+ import { DEFAULT_MAX_INTERPROC_HOPS, DEFAULT_PDG_MAX_INTERPROC_FINDINGS, } from './ingestion/taint/interproc-solver.js';
25
+ import { DEFAULT_PDG_MAX_INTERPROC_EDGES } from './ingestion/taint/interproc-emit.js';
24
26
  import { taintModelVersion } from './ingestion/taint/typescript-model.js';
25
27
  import { computeFileHashes, diffFileHashes } from '../storage/file-hash.js';
26
28
  import { extractChangedSubgraph, computeEffectiveWriteSet, } from './incremental/subgraph-extract.js';
@@ -144,6 +146,12 @@ export const resolvePdgConfig = (options) => options.pdg === true
144
146
  // writeback that populates TAINTED/SANITIZES rows without `--force`.
145
147
  maxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction ?? DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION,
146
148
  maxTaintHops: options.pdgMaxTaintHops ?? DEFAULT_PDG_MAX_TAINT_HOPS,
149
+ // #2084 review P1-3: cross-function caps. Absent on an M3-era stamp →
150
+ // pdgModeMismatch trips the first run that adds them (key-union),
151
+ // forcing the full writeback that re-materialises TAINT_PATH bounded.
152
+ maxInterprocFindings: options.pdgMaxInterprocFindings ?? DEFAULT_PDG_MAX_INTERPROC_FINDINGS,
153
+ maxInterprocHops: options.pdgMaxInterprocHops ?? DEFAULT_MAX_INTERPROC_HOPS,
154
+ maxInterprocEdges: options.pdgMaxInterprocEdges ?? DEFAULT_PDG_MAX_INTERPROC_EDGES,
147
155
  // Built-in model digest (KTD7/R7): persisted findings must never
148
156
  // outlive the model that produced them — ANY model-content change
149
157
  // ships as a new digest and repopulates the taint edges.
@@ -501,6 +509,9 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
501
509
  pdgMaxReachingDefEdgesPerFunction: options.pdgMaxReachingDefEdgesPerFunction,
502
510
  pdgMaxTaintFindingsPerFunction: options.pdgMaxTaintFindingsPerFunction,
503
511
  pdgMaxTaintHops: options.pdgMaxTaintHops,
512
+ pdgMaxInterprocFindings: options.pdgMaxInterprocFindings,
513
+ pdgMaxInterprocHops: options.pdgMaxInterprocHops,
514
+ pdgMaxInterprocEdges: options.pdgMaxInterprocEdges,
504
515
  fetchWrappers: options.fetchWrappers,
505
516
  });
506
517
  // ── Phase 2: LadybugDB (60–85%) ──────────────────────────────────
@@ -704,6 +715,15 @@ export async function runFullAnalysis(repoPath, options, callbacks) {
704
715
  // from the fresh pipeline output below. Required for the
705
716
  // "Leiden runs on the FULL graph" correctness invariant.
706
717
  await deleteAllCommunitiesAndProcesses();
718
+ // 2b. Drop interprocedural TAINT_PATH edges (#2084 M4 U6) when pdg is on
719
+ // — their validity is a whole-program property (an A→C flow can be
720
+ // invalidated by a change to an intermediate function on a third
721
+ // file), so endpoint-writability extraction can't refresh them.
722
+ // extractChangedSubgraph re-includes all of them from the fresh
723
+ // graph (isGraphWideRelType), mirroring Community/Process.
724
+ if (options.pdg === true) {
725
+ await deleteAllInterprocTaintPaths();
726
+ }
707
727
  // 3. Extract the changed subgraph from the FULL ctx.graph and write
708
728
  // only that. Unchanged-file rows in the DB stay untouched. Pass
709
729
  // the SAME effectiveWriteSet so the subgraph and the deletes
@@ -2425,16 +2425,81 @@ export class LocalBackend {
2425
2425
  }
2426
2426
  }
2427
2427
  const { rows, totalFindings } = await runAnchoredQuery();
2428
- if (totalFindings === 0 && pdgStamped === undefined && !target) {
2429
- // Meta was unreadable and the repo-wide enumerate found nothing — the
2430
- // count above WAS the existence probe; surface the layer hint.
2428
+ // M4 (#2084 U7): cross-function findings ride TAINT_PATH edges (Function/
2429
+ // Method Function/Method), separate from the intra-procedural TAINTED
2430
+ // BasicBlock rows above. Enumerate them too so `explain` is the discovery
2431
+ // surface for interprocedural flows (TAINT_PATH stays out of
2432
+ // VALID_RELATION_TYPES + the web schema, like TAINTED). File-anchored:
2433
+ // filter on the source function's file; symbol-anchored: either endpoint
2434
+ // matches the symbol name; anchorless: all (bounded by LIMIT). Computed
2435
+ // BEFORE the no-taint early returns — a repo with ONLY cross-function
2436
+ // findings (no intra-procedural TAINTED rows) must not look empty.
2437
+ const runInterprocQuery = async () => {
2438
+ const where = [`r.type = 'TAINT_PATH'`];
2439
+ const p = {};
2440
+ if (anchor?.symbol) {
2441
+ where.push('(a.name = $ipSym OR b.name = $ipSym)');
2442
+ p.ipSym = anchor.symbol;
2443
+ }
2444
+ else if (anchor?.file) {
2445
+ // Match EITHER endpoint's file — a cross-function flow anchored on the
2446
+ // SINK's file (b) is as relevant as one anchored on the source's (a).
2447
+ where.push('(a.filePath = $ipFile OR a.filePath ENDS WITH $ipSuffix OR ' +
2448
+ 'b.filePath = $ipFile OR b.filePath ENDS WITH $ipSuffix)');
2449
+ p.ipFile = anchor.file;
2450
+ p.ipSuffix = `/${anchor.file}`;
2451
+ }
2452
+ const matchClause = `MATCH (a)-[r:CodeRelation]->(b)\n WHERE ${where.join(' AND ')}`;
2453
+ // Page query + a separate COUNT (#2084 review P2-4): the page is
2454
+ // LIMIT-capped, so its row count cannot stand in for the true total —
2455
+ // run a COUNT with the same WHERE (no LIMIT) like the intra layer does.
2456
+ const [ipRows, ipCountRows] = await Promise.all([
2457
+ executeParameterized(repo.lbugPath, `${matchClause}
2458
+ RETURN a.filePath AS file, a.name AS sourceFn, a.startLine AS sourceLine,
2459
+ b.name AS sinkFn, b.startLine AS sinkLine, r.reason AS reason
2460
+ ORDER BY sourceFn, sinkFn, reason
2461
+ LIMIT ${limit}`, p),
2462
+ executeParameterized(repo.lbugPath, `${matchClause}\n RETURN COUNT(*) AS total`, p),
2463
+ ]);
2464
+ const total = Number(ipCountRows[0]?.total ?? ipCountRows[0]?.[0] ?? 0);
2465
+ const findings = ipRows.map((r) => {
2466
+ const decoded = decodeTaintPath(r.reason ?? r[5]);
2467
+ const hops = decoded.ok
2468
+ ? decoded.hops.map((h) => ({ function: h.variable, line: h.line }))
2469
+ : [];
2470
+ return {
2471
+ interprocedural: true,
2472
+ file: String(r.file ?? r[0] ?? ''),
2473
+ sinkKind: decoded.ok ? (decoded.kind ?? 'unknown') : 'unknown',
2474
+ source: { function: String(r.sourceFn ?? r[1] ?? ''), line: r.sourceLine ?? r[2] },
2475
+ sink: { function: String(r.sinkFn ?? r[3] ?? ''), line: r.sinkLine ?? r[4] },
2476
+ hops,
2477
+ ...(decoded.ok && decoded.truncated ? { pathIncomplete: true } : {}),
2478
+ };
2479
+ });
2480
+ return { findings, total };
2481
+ };
2482
+ const { findings: interprocFindings, total: interprocTotal } = await runInterprocQuery();
2483
+ if (totalFindings === 0 &&
2484
+ interprocFindings.length === 0 &&
2485
+ pdgStamped === undefined &&
2486
+ !target) {
2487
+ // Meta was unreadable and the repo-wide enumerate (both layers) found
2488
+ // nothing — the counts above WERE the existence probe; surface the hint.
2431
2489
  return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
2432
2490
  }
2433
- if (totalFindings === 0 && pdgStamped === undefined && target) {
2491
+ if (totalFindings === 0 &&
2492
+ interprocFindings.length === 0 &&
2493
+ pdgStamped === undefined &&
2494
+ target) {
2434
2495
  // Anchored miss with unreadable meta: one extra bounded probe decides
2435
- // "no findings for this anchor" vs "no taint layer at all".
2496
+ // "no findings for this anchor" vs "no taint layer at all". Probe BOTH
2497
+ // intra (TAINTED) and inter (TAINT_PATH) existence.
2436
2498
  const probe = await executeParameterized(repo.lbugPath, `MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock) WHERE r.type = 'TAINTED' RETURN r.reason AS reason LIMIT 1`, {});
2437
- if (probe.length === 0) {
2499
+ const ipProbe = probe.length === 0
2500
+ ? await executeParameterized(repo.lbugPath, `MATCH (a)-[r:CodeRelation]->(b) WHERE r.type = 'TAINT_PATH' RETURN r.reason AS reason LIMIT 1`, {})
2501
+ : [];
2502
+ if (probe.length === 0 && ipProbe.length === 0) {
2438
2503
  return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
2439
2504
  }
2440
2505
  }
@@ -2479,12 +2544,30 @@ export class LocalBackend {
2479
2544
  ...(decoded.truncated ? { pathIncomplete: true } : {}),
2480
2545
  };
2481
2546
  });
2547
+ // Combine both layers and re-apply the page LIMIT to the union — each
2548
+ // layer was queried with its own LIMIT, so the union can hold up to 2×;
2549
+ // cap it so `findings.length` honours the caller's `limit`. `truncated`
2550
+ // reflects EITHER layer overflowing OR the union being trimmed here, and
2551
+ // `totalFindings` counts both layers' matched rows (the intra COUNT plus
2552
+ // the interproc rows returned — interproc has no separate COUNT, so a
2553
+ // capped interproc layer is reflected via `truncated`, never undercounted
2554
+ // into a false "complete" signal). Review: code-review #2/#4 (explain
2555
+ // accounting + sink-file anchoring) — both layers now accounted.
2556
+ const combined = [...findings, ...interprocFindings];
2557
+ const pageFindings = combined.length > limit ? combined.slice(0, limit) : combined;
2558
+ // Truncated iff EITHER layer overflowed its own LIMIT (strict `>` — exactly
2559
+ // `limit` rows is not truncated), OR the combined union was trimmed to the
2560
+ // page (#2084 review P2-4). `totalFindings` uses the interproc COUNT, not
2561
+ // the capped slice length, so it never undercounts.
2562
+ const truncated = totalFindings > findings.length ||
2563
+ interprocTotal > interprocFindings.length ||
2564
+ combined.length > pageFindings.length;
2482
2565
  return {
2483
2566
  ...(anchor ? { anchor } : {}),
2484
- findings,
2485
- totalFindings,
2486
- ...(totalFindings > findings.length ? { truncated: true } : {}),
2487
- note: 'Intra-procedural findings only cross-function, closure/callback, property/field, and implicit flows are not modeled; absence of a finding is not proof of safety. SANITIZES (kill) edges are queryable via cypher.',
2567
+ findings: pageFindings,
2568
+ totalFindings: totalFindings + interprocTotal,
2569
+ ...(truncated ? { truncated: true } : {}),
2570
+ note: 'Intra-procedural (TAINTED, statement hops) AND cross-function (TAINT_PATH, function hops, `interprocedural: true`) flows are modeled. Closure/callback, property/field, and implicit flows are NOT modeled; absence of a finding is not proof of safety. Cross-function findings are context-insensitive and may over-attribute among same-named callees. SANITIZES (kill) edges are queryable via cypher.',
2488
2571
  };
2489
2572
  }
2490
2573
  /**
package/dist/mcp/tools.js CHANGED
@@ -473,18 +473,19 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
473
473
  },
474
474
  {
475
475
  name: 'explain',
476
- description: `Explain persisted taint findings: intra-procedural source→sink data flows (TAINTED edges) recorded by \`gitnexus analyze --pdg\`.
476
+ description: `Explain persisted taint findings recorded by \`gitnexus analyze --pdg\`: intra-procedural source→sink data flows (TAINTED edges, statement-level hops) AND cross-function flows (TAINT_PATH edges, function-level hops, marked \`interprocedural: true\`).
477
477
 
478
- Each finding carries the sink category (command-injection, code-injection, path-traversal, sql-injection, xss), the source/sink lines, and the ordered hop path with the variable carried on each hop (decoded from the persisted path encoding).
478
+ Each finding carries the sink category (command-injection, code-injection, path-traversal, sql-injection, xss) and the ordered hop path. Intra-procedural findings carry source/sink lines and the variable on each hop; interprocedural findings carry the source and sink FUNCTION names and the chain of functions the taint crossed (decoded from the persisted path encoding).
479
479
 
480
480
  WHEN TO USE: Security review — "what taint findings exist in this repo / file / function?". Requires the repo to be indexed with \`gitnexus analyze --pdg\`; without that layer the tool returns a clear "no taint layer" note, not an error.
481
481
 
482
482
  ANCHORLESS (no "target"): enumerates all persisted findings for the repo — bounded ("limit", deterministic order), with "totalFindings" and a "truncated" flag.
483
- ANCHORED ("target" = file path or symbol/function name): full hop detail for that anchor. A file-ish target (contains "/" or an extension) filters by file; a symbol name resolves like context() — ambiguous names return ranked candidates, unknown names return not-found. Symbol anchoring is line-range granular (findings whose source block starts inside the symbol's span).
483
+ ANCHORED ("target" = file path or symbol/function name): full hop detail for that anchor. A file-ish target (contains "/" or an extension) filters by file; a symbol name resolves like context() — ambiguous names return ranked candidates, unknown names return not-found. Symbol anchoring is line-range granular for intra-procedural findings; cross-function findings match when the symbol is the source OR sink function.
484
484
 
485
- CONTRACT CAVEATS (intra-procedural M3 scope — absent flows are NOT proof of safety):
486
- - Cross-function flows are not modeled (a flow through a helper function is invisible).
487
- - Closure/callback flows are invisible in both directions (e.g. arr.forEach(() => sink(y))).
485
+ CONTRACT CAVEATS (absent flows are NOT proof of safety):
486
+ - Cross-function flows ARE modeled (#2084 M4): a source flowing through helper functions into a sink is found, via summary composition over the call graph (context-insensitive — return/call-site merging is accepted).
487
+ - Cross-function matching is by callee NAME (context-insensitive): when one caller invokes two distinct same-named callees, a flow into one over-attributes to both a cross-function finding does not prove the taint reached every same-named function (sound over-report, never a missed flow).
488
+ - Closure/callback flows are invisible in both directions (e.g. arr.forEach(() => sink(y))) — the largest false-negative class.
488
489
  - Property/field flows are not tracked (obj.x = taint; sink(obj.y) has no chain).
489
490
  - Guard-style sanitizers (if (isValid(x))) and implicit/control-dependence flows are not modeled.
490
491
  - CommonJS aliasing is partially modeled (require('<literal>') joins resolve; dynamic requires do not).
@@ -145,6 +145,16 @@ export interface RepoMeta {
145
145
  * bounds the persisted hop-encoded `reason`). Optional for the same
146
146
  * M2-era-stamp upgrade reason as the findings cap. */
147
147
  maxTaintHops?: number;
148
+ /**
149
+ * Per-run cross-function caps, resolved (0 = unlimited; #2084 M4 review
150
+ * P1-3). ABSENT on an M3-era stamp — that absence trips `pdgModeMismatch`
151
+ * on the first run that adds them and forces the full writeback that
152
+ * re-materialises TAINT_PATH within bounds. Optional for that upgrade
153
+ * reason; resolved (always present) on every post-fix write.
154
+ */
155
+ maxInterprocFindings?: number;
156
+ maxInterprocHops?: number;
157
+ maxInterprocEdges?: number;
148
158
  /**
149
159
  * Digest of the built-in taint model the persisted findings were
150
160
  * produced under (#2083 M3 KTD7/R7). Any model-content change ships a