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
@@ -49,6 +49,15 @@
49
49
  */
50
50
  import { createKnowledgeGraph } from '../graph/graph.js';
51
51
  const isGraphWide = (label) => label === 'Community' || label === 'Process';
52
+ /**
53
+ * Relationship types whose VALIDITY is a whole-program property, not a
54
+ * function of their endpoints' files (#2084 M4 U6). `TAINT_PATH` (cross-
55
+ * function taint) can be invalidated by a change to an INTERMEDIATE function
56
+ * on a third file, so the endpoint-writability rule below would skip a stale
57
+ * A→C edge. These are always extracted (and the orchestrator delete-alls them
58
+ * first, like Community/Process) so they rebuild from the fresh graph.
59
+ */
60
+ const isGraphWideRelType = (type) => type === 'TAINT_PATH';
52
61
  /**
53
62
  * Build a Map<nodeId, filePath> for every File-bound node in the graph.
54
63
  * Graph-wide nodes (Community/Process) have no filePath and are filtered.
@@ -74,7 +83,9 @@ export const extractChangedSubgraph = (fullGraph, toWriteSet) => {
74
83
  }
75
84
  });
76
85
  fullGraph.forEachRelationship((r) => {
77
- if (writableNodeIds.has(r.sourceId) || writableNodeIds.has(r.targetId)) {
86
+ if (writableNodeIds.has(r.sourceId) ||
87
+ writableNodeIds.has(r.targetId) ||
88
+ isGraphWideRelType(r.type)) {
78
89
  sub.addRelationship(r);
79
90
  }
80
91
  });
@@ -15,6 +15,7 @@ export { ormPhase, type ORMOutput } from './orm.js';
15
15
  export { crossFilePhase, type CrossFileOutput } from './cross-file.js';
16
16
  export { scopeResolutionPhase, type ScopeResolutionOutput, } from '../scope-resolution/pipeline/phase.js';
17
17
  export { pruneLocalSymbolsPhase, type PruneLocalSymbolsOutput } from './prune-local-symbols.js';
18
+ export { taintSummariesPhase, type TaintSummariesOutput } from './taint-summaries.js';
18
19
  export { mroPhase, type MROOutput } from './mro.js';
19
20
  export { communitiesPhase, type CommunitiesOutput } from './communities.js';
20
21
  export { processesPhase, type ProcessesOutput } from './processes.js';
@@ -16,6 +16,7 @@ export { ormPhase } from './orm.js';
16
16
  export { crossFilePhase } from './cross-file.js';
17
17
  export { scopeResolutionPhase, } from '../scope-resolution/pipeline/phase.js';
18
18
  export { pruneLocalSymbolsPhase } from './prune-local-symbols.js';
19
+ export { taintSummariesPhase } from './taint-summaries.js';
19
20
  export { mroPhase } from './mro.js';
20
21
  export { communitiesPhase } from './communities.js';
21
22
  export { processesPhase } from './processes.js';
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Phase: taintSummaries (#2084 M4 U3/U5)
3
+ *
4
+ * The interprocedural taint fixpoint. Runs AFTER scope-resolution (where the
5
+ * complete, resolved `CALLS` graph lives in `ctx.graph` and the per-function
6
+ * summaries were harvested in-phase) and composes those summaries to find
7
+ * source→sink flows that cross function and file boundaries.
8
+ *
9
+ * Opt-in: registered with `enabledWhen: (o) => o.pdg === true` (the first real
10
+ * pdg-gated phase). A default `analyze` run never includes it, so the graph is
11
+ * byte-identical. No always-on phase depends on it (a filtered-out dep would
12
+ * throw in `getPhaseOutput`).
13
+ *
14
+ * @deps scopeResolution, pruneLocalSymbols
15
+ * @reads graph (CALLS edges, Function/Method nodes), scopeResolution output
16
+ * (functionSummaries)
17
+ * @writes graph (TAINT_PATH edges)
18
+ */
19
+ import type { PipelinePhase } from './types.js';
20
+ export interface TaintSummariesOutput {
21
+ /** Function summaries fed to the fixpoint. */
22
+ summaries: number;
23
+ /** Cross-function findings (pre-cap). */
24
+ findings: number;
25
+ /** TAINT_PATH edges persisted. */
26
+ edgesEmitted: number;
27
+ /** Call sites whose callee did not resolve to a summary edge (diagnostics). */
28
+ unmatchedCallSites: number;
29
+ }
30
+ export declare const taintSummariesPhase: PipelinePhase<TaintSummariesOutput>;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Phase: taintSummaries (#2084 M4 U3/U5)
3
+ *
4
+ * The interprocedural taint fixpoint. Runs AFTER scope-resolution (where the
5
+ * complete, resolved `CALLS` graph lives in `ctx.graph` and the per-function
6
+ * summaries were harvested in-phase) and composes those summaries to find
7
+ * source→sink flows that cross function and file boundaries.
8
+ *
9
+ * Opt-in: registered with `enabledWhen: (o) => o.pdg === true` (the first real
10
+ * pdg-gated phase). A default `analyze` run never includes it, so the graph is
11
+ * byte-identical. No always-on phase depends on it (a filtered-out dep would
12
+ * throw in `getPhaseOutput`).
13
+ *
14
+ * @deps scopeResolution, pruneLocalSymbols
15
+ * @reads graph (CALLS edges, Function/Method nodes), scopeResolution output
16
+ * (functionSummaries)
17
+ * @writes graph (TAINT_PATH edges)
18
+ */
19
+ import { getPhaseOutput } from './types.js';
20
+ import { solveInterprocTaint, DEFAULT_MAX_INTERPROC_HOPS, DEFAULT_PDG_MAX_INTERPROC_FINDINGS, } from '../taint/interproc-solver.js';
21
+ import { emitInterprocTaint, DEFAULT_PDG_MAX_INTERPROC_EDGES } from '../taint/interproc-emit.js';
22
+ import { logger } from '../../logger.js';
23
+ const EMPTY = {
24
+ summaries: 0,
25
+ findings: 0,
26
+ edgesEmitted: 0,
27
+ unmatchedCallSites: 0,
28
+ };
29
+ export const taintSummariesPhase = {
30
+ name: 'taintSummaries',
31
+ deps: ['scopeResolution', 'pruneLocalSymbols'],
32
+ async execute(ctx, deps) {
33
+ const scope = getPhaseOutput(deps, 'scopeResolution');
34
+ const summaries = scope.functionSummaries;
35
+ if (summaries.length === 0)
36
+ return EMPTY;
37
+ // Index summaries by function node id.
38
+ const summaryMap = new Map(summaries.map((s) => [s.fnId, s]));
39
+ // Build the call-edge adjacency from resolved CALLS edges. The join to a
40
+ // summary's call-arg edge is by CALLEE NAME (base-independent — see the
41
+ // solver doc); recover it from the callee node's `name` property.
42
+ const callEdges = [];
43
+ for (const rel of ctx.graph.iterRelationshipsByType('CALLS')) {
44
+ const callee = ctx.graph.getNode(rel.targetId);
45
+ const calleeName = callee && typeof callee.properties.name === 'string' ? callee.properties.name : undefined;
46
+ if (calleeName === undefined)
47
+ continue;
48
+ callEdges.push({ callerId: rel.sourceId, calleeId: rel.targetId, calleeName });
49
+ }
50
+ // Arm the per-run caps (#2084 review P1-3) — every other pdg layer bounds
51
+ // its output via RepoMeta.pdg; without this the fixpoint state + TAINT_PATH
52
+ // edges grow unbounded on a fan-in-heavy repo (OOM). `0` ⇒ unlimited
53
+ // (preserved like the other pdg caps). The solver/emit already implement
54
+ // deterministic truncate-and-warn — this just hands them the budgets.
55
+ const maxFindings = ctx.options?.pdgMaxInterprocFindings ?? DEFAULT_PDG_MAX_INTERPROC_FINDINGS;
56
+ const maxHops = ctx.options?.pdgMaxInterprocHops ?? DEFAULT_MAX_INTERPROC_HOPS;
57
+ const maxEdges = ctx.options?.pdgMaxInterprocEdges ?? DEFAULT_PDG_MAX_INTERPROC_EDGES;
58
+ const solved = solveInterprocTaint(summaryMap, callEdges, { maxFindings, maxHops });
59
+ const emit = emitInterprocTaint(ctx.graph, solved.findings, { maxEdges }, (m) => logger.warn(m));
60
+ // Surface drops UNCONDITIONALLY (R4 — never silently truncate the layer).
61
+ if (solved.droppedFindings > 0 || emit.edgesDropped > 0) {
62
+ logger.warn(`[taint-interproc] capped: ${solved.droppedFindings} finding(s) dropped by the ` +
63
+ `per-run findings cap (${maxFindings}), ${emit.edgesDropped} edge(s) by the edge cap ` +
64
+ `(${maxEdges}) — raise pdgMaxInterprocFindings/pdgMaxInterprocEdges if intentional`);
65
+ }
66
+ if (solved.findings.length > 0 || emit.edgesEmitted > 0) {
67
+ logger.debug(`[taint-interproc] ${summaries.length} summaries, ${callEdges.length} CALLS edges → ` +
68
+ `${solved.findings.length} cross-function finding(s), ${emit.edgesEmitted} TAINT_PATH edge(s)` +
69
+ (emit.hopsTruncated > 0 ? `, ${emit.hopsTruncated} with truncated paths` : '') +
70
+ (solved.unmatchedCallSites > 0
71
+ ? `, ${solved.unmatchedCallSites} unmatched call site(s)`
72
+ : ''));
73
+ }
74
+ return {
75
+ summaries: summaries.length,
76
+ findings: solved.findings.length,
77
+ edgesEmitted: emit.edgesEmitted,
78
+ unmatchedCallSites: solved.unmatchedCallSites,
79
+ };
80
+ },
81
+ };
@@ -73,6 +73,19 @@ export interface PipelineOptions {
73
73
  * no-CLI-flag discipline as `pdgMaxTaintFindingsPerFunction`.
74
74
  */
75
75
  pdgMaxTaintHops?: number;
76
+ /**
77
+ * Per-run cross-function findings cap (#2084 M4 review P1-3). `undefined` ⇒
78
+ * `DEFAULT_PDG_MAX_INTERPROC_FINDINGS` (2000); `0` ⇒ no cap. Consumed by the
79
+ * `taintSummaries` phase; RepoMeta-stamped, no CLI flag (KTD8) — same
80
+ * discipline as the per-function taint caps.
81
+ */
82
+ pdgMaxInterprocFindings?: number;
83
+ /** Per-finding cross-function hop cap (#2084 review P1-3). `undefined` ⇒
84
+ * `DEFAULT_MAX_INTERPROC_HOPS` (32); `0` ⇒ no cap. */
85
+ pdgMaxInterprocHops?: number;
86
+ /** Per-run `TAINT_PATH` edge cap (#2084 review P1-3). `undefined` ⇒
87
+ * `DEFAULT_PDG_MAX_INTERPROC_EDGES` (1000); `0` ⇒ no cap. */
88
+ pdgMaxInterprocEdges?: number;
76
89
  /**
77
90
  * Request parsing with the worker pool disabled. The sequential parser was
78
91
  * removed — the worker pool is the sole parse path — so setting this now
@@ -15,7 +15,7 @@
15
15
  * See ARCHITECTURE.md for the full phase dependency diagram.
16
16
  */
17
17
  import { createKnowledgeGraph } from '../graph/graph.js';
18
- import { runPipeline, getPhaseOutput, scanPhase, structurePhase, markdownPhase, cobolPhase, parsePhase, routesPhase, toolsPhase, ormPhase, crossFilePhase, scopeResolutionPhase, pruneLocalSymbolsPhase, mroPhase, communitiesPhase, processesPhase, PhaseRegistry, } from './pipeline-phases/index.js';
18
+ import { runPipeline, getPhaseOutput, scanPhase, structurePhase, markdownPhase, cobolPhase, parsePhase, routesPhase, toolsPhase, ormPhase, crossFilePhase, scopeResolutionPhase, pruneLocalSymbolsPhase, taintSummariesPhase, mroPhase, communitiesPhase, processesPhase, PhaseRegistry, } from './pipeline-phases/index.js';
19
19
  // ── Phase registry ─────────────────────────────────────────────────────────
20
20
  /**
21
21
  * All pipeline phases with their dependency relationships.
@@ -49,6 +49,10 @@ export function buildPhaseList(options) {
49
49
  .register(crossFilePhase)
50
50
  .register(scopeResolutionPhase)
51
51
  .register(pruneLocalSymbolsPhase)
52
+ // M4 (#2084): interprocedural taint fixpoint — the first real opt-in
53
+ // pdg-gated phase. Off ⇒ absent ⇒ byte-identical graph. No always-on
54
+ // phase depends on it (a filtered-out dep would throw in getPhaseOutput).
55
+ .register(taintSummariesPhase, { enabledWhen: (o) => o.pdg === true })
52
56
  .register(mroPhase, { enabledWhen: (o) => !o.skipGraphPhases })
53
57
  .register(communitiesPhase, { enabledWhen: (o) => !o.skipGraphPhases })
54
58
  .register(processesPhase, { enabledWhen: (o) => !o.skipGraphPhases })
@@ -26,6 +26,7 @@
26
26
  import type { PipelinePhase } from '../../pipeline-phases/types.js';
27
27
  import { SupportedLanguages } from '../../../../_shared/index.js';
28
28
  import type { ResolutionOutcome } from '../resolution-outcome.js';
29
+ import type { FunctionSummary } from '../../taint/summary-model.js';
29
30
  export interface ScopeResolutionOutput {
30
31
  /** True when at least one language ran. */
31
32
  readonly ran: boolean;
@@ -43,5 +44,11 @@ export interface ScopeResolutionOutput {
43
44
  readonly importsEmitted: number;
44
45
  readonly referenceEdgesEmitted: number;
45
46
  }>;
47
+ /**
48
+ * Per-function taint summaries harvested in the pdg window (#2084 M4 U1),
49
+ * across all languages. Empty unless `--pdg` and a registered taint model.
50
+ * The `taintSummaries` phase composes these over the `CALLS` graph.
51
+ */
52
+ readonly functionSummaries: readonly FunctionSummary[];
46
53
  }
47
54
  export declare const scopeResolutionPhase: PipelinePhase<ScopeResolutionOutput>;
@@ -33,6 +33,7 @@ import { SCOPE_RESOLVERS } from './registry.js';
33
33
  import { isDev, isSemanticModelValidatorEnabled } from '../../utils/env.js';
34
34
  import { logHeapProbe } from '../../utils/heap-probe.js';
35
35
  import { clearParsedFileStore, loadParsedFilesForPaths, forceGc, } from '../../../../storage/parsedfile-store.js';
36
+ import { buildFunctionNodeIndex } from '../../taint/summary-harvest-driver.js';
36
37
  import { logger } from '../../../logger.js';
37
38
  const NOOP_OUTPUT = Object.freeze({
38
39
  ran: false,
@@ -41,6 +42,7 @@ const NOOP_OUTPUT = Object.freeze({
41
42
  referenceEdgesEmitted: 0,
42
43
  resolutionOutcomes: [],
43
44
  perLanguage: new Map(),
45
+ functionSummaries: [],
44
46
  });
45
47
  export const scopeResolutionPhase = {
46
48
  name: 'scopeResolution',
@@ -103,6 +105,9 @@ export const scopeResolutionPhase = {
103
105
  let totalRefs = 0;
104
106
  let anyRan = false;
105
107
  const resolutionOutcomes = [];
108
+ // M4 (#2084 U1): per-function taint summaries accumulated across every
109
+ // language pass; the cross-function fixpoint phase reads this output.
110
+ const functionSummaries = [];
106
111
  const perLanguage = new Map();
107
112
  // Pre-count files and languages for progress reporting. This avoids
108
113
  // a frozen progress bar during long scope-resolution runs (#1741).
@@ -167,6 +172,13 @@ export const scopeResolutionPhase = {
167
172
  logHeapProbe('scope-setup-nodeLookup-start', `langs=${totalScopeLangs} files=${totalScopeFiles}`);
168
173
  const sharedNodeLookup = totalScopeFiles > 0 ? buildGraphNodeLookup(ctx.graph) : undefined;
169
174
  logHeapProbe('scope-setup-nodeLookup-end', `langs=${totalScopeLangs}`);
175
+ // M4 (#2084 review P2-6): build the functionish-node index ONCE for the
176
+ // taint summary harvest, shared across every language pass (it is a whole-
177
+ // graph scan and language-agnostic). Only when pdg is on — off ⇒ undefined,
178
+ // no scan, byte-identical.
179
+ const sharedFnNodeIndex = ctx.options?.pdg === true && totalScopeFiles > 0
180
+ ? buildFunctionNodeIndex(ctx.graph)
181
+ : undefined;
170
182
  for (const [lang, provider] of SCOPE_RESOLVERS) {
171
183
  // Standalone providers (COBOL, JCL) don't emit graph edges yet
172
184
  // through the scope-resolution path. This is the canonical guard:
@@ -283,6 +295,7 @@ export const scopeResolutionPhase = {
283
295
  files,
284
296
  resolutionConfig,
285
297
  prebuiltNodeLookup: sharedNodeLookup,
298
+ prebuiltFunctionNodeIndex: sharedFnNodeIndex,
286
299
  preExtractedParsedFiles: preExtractedByPath,
287
300
  scopeIndexStorePath: parsedFileStorePath,
288
301
  // CFG/PDG emission (#2081 M1) — opt-in; off ⇒ byte-identical graph.
@@ -360,6 +373,7 @@ export const scopeResolutionPhase = {
360
373
  logHeapProbe('scope-lang-end', `lang=${lang} filesProcessed=${stats.filesProcessed}`);
361
374
  processedScopeFiles += langFileCount;
362
375
  anyRan = true;
376
+ functionSummaries.push(...stats.functionSummaries);
363
377
  totalFiles += stats.filesProcessed;
364
378
  totalImports += stats.importsEmitted;
365
379
  totalRefs += stats.referenceEdgesEmitted;
@@ -401,6 +415,7 @@ export const scopeResolutionPhase = {
401
415
  referenceEdgesEmitted: totalRefs,
402
416
  resolutionOutcomes,
403
417
  perLanguage,
418
+ functionSummaries,
404
419
  };
405
420
  },
406
421
  };
@@ -27,6 +27,8 @@ import type { KnowledgeGraph } from '../../../graph/types.js';
27
27
  import type { MutableSemanticModel } from '../../model/semantic-model.js';
28
28
  import { type ResolveStats } from '../../resolve-references.js';
29
29
  import { buildGraphNodeLookup } from '../graph-bridge/node-lookup.js';
30
+ import { type FunctionNodeIndex } from '../../taint/summary-harvest-driver.js';
31
+ import type { FunctionSummary } from '../../taint/summary-model.js';
30
32
  import type { ScopeResolver } from '../contract/scope-resolver.js';
31
33
  import type { ResolutionOutcome, ResolutionOutcomeRecorder } from '../resolution-outcome.js';
32
34
  export type ScopeResolutionSubPhase = 'extracting' | 'analyzing types' | 'resolving references' | 'linking symbols';
@@ -91,6 +93,14 @@ interface RunScopeResolutionInput {
91
93
  * base is safe.
92
94
  */
93
95
  readonly prebuiltNodeLookup?: ReturnType<typeof buildGraphNodeLookup>;
96
+ /**
97
+ * Functionish-node index built ONCE by the caller and shared across every
98
+ * language pass (#2084 review P2-6). Like `prebuiltNodeLookup`,
99
+ * `buildFunctionNodeIndex` is a whole-graph scan and is language-agnostic, so
100
+ * rebuilding it per language wastes a full scan each time. When omitted
101
+ * (tests / isolated calls) it is built locally for the pdg-enabled language.
102
+ */
103
+ readonly prebuiltFunctionNodeIndex?: FunctionNodeIndex;
94
104
  /**
95
105
  * Opaque per-language import-resolution config (e.g. tsconfig path
96
106
  * aliases for TypeScript). Loaded once by the caller via
@@ -148,6 +158,13 @@ interface RunScopeResolutionStats {
148
158
  readonly referenceEdgesEmitted: number;
149
159
  readonly referenceSkipped: number;
150
160
  readonly resolutionOutcomes: readonly ResolutionOutcome[];
161
+ /**
162
+ * Per-function taint summaries harvested in the pdg window (#2084 M4 U1).
163
+ * Empty unless `input.pdg === true` and the language has a registered taint
164
+ * model. Keyed by resolved `Function`/`Method` node id; the cross-function
165
+ * fixpoint phase composes them over the complete `CALLS` graph.
166
+ */
167
+ readonly functionSummaries: readonly FunctionSummary[];
151
168
  }
152
169
  export declare function runScopeResolution(input: RunScopeResolutionInput, provider: ScopeResolver): RunScopeResolutionStats;
153
170
  export {};
@@ -30,10 +30,11 @@ 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, REACHING_DEF_FACTS_PER_EDGE_CAP, } from '../../cfg/emit.js';
33
+ import { emitFileCfgs, emitFileReachingDefs, isEmitSafeCfg, DEFAULT_MAX_CFG_EDGES_PER_FUNCTION, DEFAULT_PDG_MAX_REACHING_DEF_EDGES_PER_FUNCTION, DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION, REACHING_DEF_FACTS_PER_EDGE_CAP, } from '../../cfg/emit.js';
34
34
  import { emitFileTaint, DEFAULT_PDG_MAX_TAINT_FINDINGS_PER_FUNCTION, DEFAULT_PDG_MAX_TAINT_HOPS, } from '../../taint/emit.js';
35
35
  import { registerBuiltinTaintModels } from '../../taint/typescript-model.js';
36
36
  import { getSourceSinkConfig } from '../../taint/source-sink-registry.js';
37
+ import { buildFunctionNodeIndex, harvestFileSummaries, } from '../../taint/summary-harvest-driver.js';
37
38
  import { resolveDefGraphId } from '../graph-bridge/ids.js';
38
39
  import { buildPopulatedMethodDispatch } from '../graph-bridge/method-dispatch.js';
39
40
  import { propagateImportedReturnTypes } from '../passes/imported-return-types.js';
@@ -290,6 +291,7 @@ export function runScopeResolution(input, provider) {
290
291
  referenceEdgesEmitted: 0,
291
292
  referenceSkipped: 0,
292
293
  resolutionOutcomes,
294
+ functionSummaries: [],
293
295
  };
294
296
  }
295
297
  const tExtract = PROF ? process.hrtime.bigint() : 0n;
@@ -471,6 +473,11 @@ export function runScopeResolution(input, provider) {
471
473
  // pair can't bracket them; without this accumulator the M2 cost would
472
474
  // silently disappear into `emit=` and field regressions would be invisible.
473
475
  let pdgMs = 0;
476
+ // M4 (#2084 U1): per-function taint summaries harvested in the pdg window,
477
+ // returned on the stats for the cross-function fixpoint phase. Function-scoped
478
+ // so the return (below the pdg block) can read it; empty on non-pdg runs.
479
+ const harvestedSummaries = [];
480
+ let summaryUnresolved = 0;
474
481
  // M3 (#2083 U4): accumulated taint time (match + taint-side solve +
475
482
  // propagate + TAINTED/SANITIZES emit), a sibling of `pdgMs` for the same
476
483
  // reason — it interleaves per file inside `emit=`, so only an accumulator
@@ -523,6 +530,13 @@ export function runScopeResolution(input, provider) {
523
530
  gapExamples: [],
524
531
  dropExamples: [],
525
532
  };
533
+ // M4 (#2084 U1): per-function summary harvest. The functionish-node index
534
+ // is built ONCE (whole-graph scan) and reused across every file; summaries
535
+ // accumulate here and ride out on the stats for the cross-function fixpoint
536
+ // phase. Only built when the language has a registered taint model.
537
+ const fnNodeIndex = taintSpec !== undefined
538
+ ? (input.prebuiltFunctionNodeIndex ?? buildFunctionNodeIndex(graph))
539
+ : undefined;
526
540
  for (const pf of emitParsedFiles) {
527
541
  const cfgs = pf.cfgSideChannel;
528
542
  // Defensive: cfgSideChannel is opaque (`unknown`) and crosses the cache /
@@ -595,6 +609,19 @@ export function runScopeResolution(input, provider) {
595
609
  if (taintTotals.dropExamples.length < 5)
596
610
  taintTotals.dropExamples.push(ex);
597
611
  }
612
+ // M4 (#2084 U1): harvest per-function summaries over the SAME
613
+ // emit-safe CFGs, inside the SAME per-file try. Pure aside from the
614
+ // read-only node-index lookup; the cross-function fixpoint phase
615
+ // consumes `harvestedSummaries` once the whole call graph is built.
616
+ if (fnNodeIndex !== undefined) {
617
+ const harvest = harvestFileSummaries(fnNodeIndex, wellFormed, pf.parsedImports, taintSpec,
618
+ // Same fact cap the taint-side RD solve uses (coverage parity).
619
+ taintLimits.maxFacts && taintLimits.maxFacts > 0
620
+ ? taintLimits.maxFacts
621
+ : DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION);
622
+ harvestedSummaries.push(...harvest.summaries);
623
+ summaryUnresolved += harvest.unresolved;
624
+ }
598
625
  }
599
626
  }
600
627
  catch (err) {
@@ -657,6 +684,14 @@ export function runScopeResolution(input, provider) {
657
684
  logger.warn(`[taint] lang=${provider.language}: ${parts.join('; ')}`);
658
685
  }
659
686
  }
687
+ // M4 (#2084 U1): summary harvest volume + anchor-resolution diagnostics.
688
+ if (harvestedSummaries.length > 0 || summaryUnresolved > 0) {
689
+ logger.debug(`[taint-summary] lang=${provider.language}: ${harvestedSummaries.length} function ` +
690
+ `summary/summaries harvested` +
691
+ (summaryUnresolved > 0
692
+ ? `, ${summaryUnresolved} CFG anchor(s) unresolved (same-line collision or missing node)`
693
+ : ''));
694
+ }
660
695
  }
661
696
  if (PROF) {
662
697
  const tEnd = process.hrtime.bigint();
@@ -681,5 +716,6 @@ export function runScopeResolution(input, provider) {
681
716
  referenceEdgesEmitted: emitted + receiverExtras + unresolvedReceiverExtras + freeCallExtras,
682
717
  referenceSkipped: skipped,
683
718
  resolutionOutcomes,
719
+ functionSummaries: harvestedSummaries,
684
720
  };
685
721
  }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Interprocedural taint emission (#2084 M4 U4) — materialise `TAINT_PATH`.
3
+ *
4
+ * Persists each cross-function {@link InterprocFinding} as ONE `TAINT_PATH`
5
+ * edge from the source function node to the sink function node, with the
6
+ * function-level hop chain + sink kind encoded in `reason` via the SHARED
7
+ * `path-codec` (the same versioned wire format M3's intra-procedural `TAINTED`
8
+ * edges use — never a second hand-rolled codec). The MCP `explain` tool decodes
9
+ * it for cross-function path rendering (U7).
10
+ *
11
+ * `TAINT_PATH` was reserved at M0 (the RelationshipType + the `CodeRelation`
12
+ * Function/Method node-pairs already exist), so materialisation needs zero
13
+ * schema work. Like `TAINTED`, it stays out of `VALID_RELATION_TYPES` and the
14
+ * web schema — `explain` is the discovery surface.
15
+ *
16
+ * Boundedness mirrors the M3 emit driver: dedup-before-cap (the solver already
17
+ * deduped by `(source, sink, kind)`), a per-run findings cap, and unconditional
18
+ * truncate-and-warn — never a silent drop.
19
+ */
20
+ import type { KnowledgeGraph } from '../../graph/types.js';
21
+ import type { InterprocFinding } from './interproc-solver.js';
22
+ /** Confidence stamped on interprocedural `TAINT_PATH` edges. Lower than the
23
+ * intra-procedural `TAINTED` 1.0 — context-insensitive composition is a
24
+ * coarser signal (return/call-site merging). */
25
+ export declare const INTERPROC_TAINT_CONFIDENCE = 0.6;
26
+ /**
27
+ * Default per-run cap on emitted `TAINT_PATH` edges (#2084 review P1-3).
28
+ * Resolved into `RepoMeta.pdg` like the other pdg caps; `0` ⇒ unlimited.
29
+ */
30
+ export declare const DEFAULT_PDG_MAX_INTERPROC_EDGES = 1000;
31
+ export interface InterprocEmitLimits {
32
+ /** Max `TAINT_PATH` edges per run (post-dedup). `undefined`/0 ⇒ unlimited. */
33
+ readonly maxEdges?: number;
34
+ }
35
+ export interface InterprocEmitResult {
36
+ /** TAINT_PATH edges persisted. */
37
+ edgesEmitted: number;
38
+ /** Findings dropped by the per-run cap. */
39
+ edgesDropped: number;
40
+ /** Findings whose persisted hop path is a truncated prefix. */
41
+ hopsTruncated: number;
42
+ /** Findings skipped because an endpoint node was missing from the graph. */
43
+ skippedMissingEndpoint: number;
44
+ }
45
+ /**
46
+ * Persist cross-function findings as `TAINT_PATH` edges. `findings` is assumed
47
+ * deduped + deterministically ordered (the solver's contract). Never throws on
48
+ * valid input.
49
+ */
50
+ export declare function emitInterprocTaint(graph: KnowledgeGraph, findings: readonly InterprocFinding[], limits?: InterprocEmitLimits, onWarn?: (message: string) => void): InterprocEmitResult;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Interprocedural taint emission (#2084 M4 U4) — materialise `TAINT_PATH`.
3
+ *
4
+ * Persists each cross-function {@link InterprocFinding} as ONE `TAINT_PATH`
5
+ * edge from the source function node to the sink function node, with the
6
+ * function-level hop chain + sink kind encoded in `reason` via the SHARED
7
+ * `path-codec` (the same versioned wire format M3's intra-procedural `TAINTED`
8
+ * edges use — never a second hand-rolled codec). The MCP `explain` tool decodes
9
+ * it for cross-function path rendering (U7).
10
+ *
11
+ * `TAINT_PATH` was reserved at M0 (the RelationshipType + the `CodeRelation`
12
+ * Function/Method node-pairs already exist), so materialisation needs zero
13
+ * schema work. Like `TAINTED`, it stays out of `VALID_RELATION_TYPES` and the
14
+ * web schema — `explain` is the discovery surface.
15
+ *
16
+ * Boundedness mirrors the M3 emit driver: dedup-before-cap (the solver already
17
+ * deduped by `(source, sink, kind)`), a per-run findings cap, and unconditional
18
+ * truncate-and-warn — never a silent drop.
19
+ */
20
+ import { encodeTaintPath } from './path-codec.js';
21
+ /** Confidence stamped on interprocedural `TAINT_PATH` edges. Lower than the
22
+ * intra-procedural `TAINTED` 1.0 — context-insensitive composition is a
23
+ * coarser signal (return/call-site merging). */
24
+ export const INTERPROC_TAINT_CONFIDENCE = 0.6;
25
+ /**
26
+ * Default per-run cap on emitted `TAINT_PATH` edges (#2084 review P1-3).
27
+ * Resolved into `RepoMeta.pdg` like the other pdg caps; `0` ⇒ unlimited.
28
+ */
29
+ export const DEFAULT_PDG_MAX_INTERPROC_EDGES = 1000;
30
+ /**
31
+ * Persist cross-function findings as `TAINT_PATH` edges. `findings` is assumed
32
+ * deduped + deterministically ordered (the solver's contract). Never throws on
33
+ * valid input.
34
+ */
35
+ export function emitInterprocTaint(graph, findings, limits, onWarn) {
36
+ const result = {
37
+ edgesEmitted: 0,
38
+ edgesDropped: 0,
39
+ hopsTruncated: 0,
40
+ skippedMissingEndpoint: 0,
41
+ };
42
+ const maxEdges = limits?.maxEdges && limits.maxEdges > 0 ? limits.maxEdges : Infinity;
43
+ const seen = new Set();
44
+ for (const finding of findings) {
45
+ if (result.edgesEmitted >= maxEdges) {
46
+ result.edgesDropped++;
47
+ continue;
48
+ }
49
+ const sourceNode = graph.getNode(finding.sourceFnId);
50
+ const sinkNode = graph.getNode(finding.sinkFnId);
51
+ if (!sourceNode || !sinkNode) {
52
+ result.skippedMissingEndpoint++;
53
+ continue;
54
+ }
55
+ // Map function hops → codec hops. The hop "name" is the function's display
56
+ // name (identifier charset — codec-safe); the line is its start line.
57
+ const hops = finding.hops.map((h) => {
58
+ const node = graph.getNode(h.fnId);
59
+ const name = typeof node?.properties.name === 'string' ? node.properties.name : 'fn';
60
+ const line = typeof node?.properties.startLine === 'number' ? node.properties.startLine : 0;
61
+ return { name, line };
62
+ });
63
+ const encoded = encodeTaintPath(hops, {
64
+ kind: finding.sinkKind,
65
+ truncated: finding.hopsTruncated,
66
+ });
67
+ if (encoded.truncated)
68
+ result.hopsTruncated++;
69
+ const id = `rel:TAINT_PATH:${finding.sinkKind}:${finding.sourceFnId}=>${finding.sinkFnId}`;
70
+ if (seen.has(id))
71
+ continue;
72
+ seen.add(id);
73
+ graph.addRelationship({
74
+ id,
75
+ sourceId: finding.sourceFnId,
76
+ targetId: finding.sinkFnId,
77
+ type: 'TAINT_PATH',
78
+ confidence: INTERPROC_TAINT_CONFIDENCE,
79
+ reason: encoded.reason,
80
+ });
81
+ result.edgesEmitted++;
82
+ }
83
+ if (result.edgesDropped > 0) {
84
+ onWarn?.(`[taint-interproc] ${result.edgesDropped} cross-function finding(s) dropped by the ` +
85
+ `per-run TAINT_PATH cap (${maxEdges})`);
86
+ }
87
+ return result;
88
+ }