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
@@ -27,8 +27,22 @@ import { getExactScanLimit, isVectorExtensionSupportedByPlatform, } from '../../
27
27
  import { PhaseTimer } from '../../core/search/phase-timer.js';
28
28
  import { checkStalenessAsync, checkCwdMatch } from '../../core/git-staleness.js';
29
29
  import { logger } from '../../core/logger.js';
30
- import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT } from '../tools.js';
30
+ import { LIST_REPOS_DEFAULT_LIMIT, LIST_REPOS_MAX_LIMIT, EXPLAIN_DEFAULT_LIMIT, EXPLAIN_MAX_LIMIT, } from '../tools.js';
31
31
  import { findImportCycles } from '../../core/graph/import-cycles.js';
32
+ import { decodeTaintPath } from '../../core/ingestion/taint/path-codec.js';
33
+ import { EXTENSIONS } from '../../core/ingestion/import-resolvers/utils.js';
34
+ /** Real source-file extensions (`.ts`, `.py`, …) from the resolver's list,
35
+ * excluding the empty entry and the `/index.*` forms — used to decide whether
36
+ * an `explain` target is a file path vs a (possibly dotted) symbol name. */
37
+ const SOURCE_FILE_EXTENSIONS = EXTENSIONS.filter((e) => e.startsWith('.') && !e.includes('/'));
38
+ /** A target is path-ish if it has a path separator or ends in a known source
39
+ * extension. A bare dotted symbol (`UserController.create`) is NOT path-ish. */
40
+ function looksLikeFilePath(target) {
41
+ if (/[\\/]/.test(target))
42
+ return true;
43
+ const lower = target.toLowerCase();
44
+ return SOURCE_FILE_EXTENSIONS.some((ext) => lower.endsWith(ext));
45
+ }
32
46
  // AI context generation is CLI-only (gitnexus analyze)
33
47
  // import { generateAIContextFiles } from '../../cli/ai-context.js';
34
48
  /**
@@ -1034,6 +1048,8 @@ export class LocalBackend {
1034
1048
  }
1035
1049
  case 'context':
1036
1050
  return this.context(repo, params);
1051
+ case 'explain':
1052
+ return this.explain(repo, params);
1037
1053
  case 'impact':
1038
1054
  return this.impact(repo, params);
1039
1055
  case 'detect_changes':
@@ -2247,6 +2263,230 @@ export class LocalBackend {
2247
2263
  })),
2248
2264
  };
2249
2265
  }
2266
+ /**
2267
+ * Explain tool (#2083 M3 U6) — persisted taint-finding explanation.
2268
+ * WAL-aware wrapper mirroring `context`.
2269
+ */
2270
+ async explain(repo, params) {
2271
+ try {
2272
+ return await this._explainImpl(repo, params);
2273
+ }
2274
+ catch (err) {
2275
+ const msg = (err instanceof Error ? err.message : String(err)) || 'Explain query failed';
2276
+ if (isWalCorruptionError(err)) {
2277
+ return {
2278
+ error: msg,
2279
+ recoverySuggestion: WAL_RECOVERY_SUGGESTION,
2280
+ };
2281
+ }
2282
+ throw err;
2283
+ }
2284
+ }
2285
+ /**
2286
+ * Taint findings are persisted as `TAINTED` rows in CodeRelation whose
2287
+ * endpoints are BOTH BasicBlock nodes — the label anchor restricts every
2288
+ * query here to the BasicBlock→BasicBlock partition of the rel table
2289
+ * (which holds only the sparse, per-function-capped pdg layers), never a
2290
+ * global symbol-space scan (the S1 verdict; LadybugDB has no rel-property
2291
+ * index, so the label anchor IS the bound).
2292
+ *
2293
+ * Anchoring granularity:
2294
+ * - file target → BasicBlock id prefix (`BasicBlock:<filePath>:` — the
2295
+ * shared `basicBlockId` template) with an exact-or-suffix path match so
2296
+ * `vuln.ts` finds `src/vuln.ts`.
2297
+ * - symbol target → resolved via `resolveSymbolCandidates` (the context()
2298
+ * path: ambiguous ⇒ ranked candidates, unknown ⇒ not-found), then the
2299
+ * file id-prefix PLUS source-block startLine within the symbol's
2300
+ * [startLine, endLine] span. Findings are intra-procedural, so filtering
2301
+ * the SOURCE endpoint is sufficient — both endpoints share the function.
2302
+ * Symbols without a line span degrade to the file-level filter.
2303
+ *
2304
+ * The per-finding `sinkKind` and hop path decode from the persisted
2305
+ * `reason` via the SHARED `taint/path-codec.ts` (the U4 write path encodes
2306
+ * with the same module — `;<kind>` header + ordered `variable:line` hops).
2307
+ */
2308
+ async _explainImpl(repo, params) {
2309
+ await this.ensureInitialized(repo);
2310
+ const rawLimit = params.limit ?? EXPLAIN_DEFAULT_LIMIT;
2311
+ if (!Number.isInteger(rawLimit) || rawLimit < 1 || rawLimit > EXPLAIN_MAX_LIMIT) {
2312
+ return {
2313
+ error: `Invalid "limit": expected an integer in [1, ${EXPLAIN_MAX_LIMIT}], got ${JSON.stringify(params.limit)}.`,
2314
+ };
2315
+ }
2316
+ const limit = rawLimit;
2317
+ const NO_TAINT_NOTE = 'no taint layer — run gitnexus analyze --pdg to record taint findings for this repo';
2318
+ // Cheap meta probe: the TAINT layer exists iff the pdg stamp carries a
2319
+ // `taintModelVersion` (the field M3 added). An M1/M2-era `--pdg` index has
2320
+ // `meta.pdg` defined but no taintModelVersion — BasicBlock/REACHING_DEF
2321
+ // exist, zero TAINTED rows do — so it must surface the no-taint-layer hint,
2322
+ // not the generic "analyzed, nothing found" note. An unreadable meta (e.g.
2323
+ // a seeded test DB) falls through to the row-existence probe below.
2324
+ let pdgStamped;
2325
+ try {
2326
+ const meta = await loadMeta(path.dirname(repo.lbugPath));
2327
+ if (meta)
2328
+ pdgStamped = meta.pdg?.taintModelVersion !== undefined;
2329
+ }
2330
+ catch {
2331
+ /* meta unreadable — decide from the DB below */
2332
+ }
2333
+ if (pdgStamped === false) {
2334
+ return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
2335
+ }
2336
+ // Resolve the optional anchor into a WHERE clause on the SOURCE block.
2337
+ const target = typeof params.target === 'string' ? params.target.trim() : '';
2338
+ let anchorClause = '';
2339
+ const queryParams = {};
2340
+ let anchor;
2341
+ // Build the anchor as a file filter (used only when `target` is path-ish).
2342
+ const buildFileAnchor = () => {
2343
+ // Exact path via the BasicBlock id-prefix template, OR a
2344
+ // path-separator-aligned suffix so partial paths work like context()'s
2345
+ // file_path hint ("vuln.ts" ⇒ "src/vuln.ts", never "devuln.ts").
2346
+ anchorClause =
2347
+ 'AND (a.id STARTS WITH $idPrefix OR a.filePath = $targetPath OR a.filePath ENDS WITH $targetSuffix)';
2348
+ queryParams.idPrefix = `BasicBlock:${target}:`;
2349
+ queryParams.targetPath = target;
2350
+ queryParams.targetSuffix = `/${target}`;
2351
+ anchor = { file: target };
2352
+ };
2353
+ // Resolve `target` as a symbol into the anchor. Returns an early-return
2354
+ // payload (not_found / ambiguous) or undefined on success.
2355
+ const resolveSymbolAnchor = async () => {
2356
+ const outcome = await this.resolveSymbolCandidates(repo, { name: target }, {});
2357
+ if (outcome.kind === 'not_found') {
2358
+ return { error: `Symbol '${target}' not found` };
2359
+ }
2360
+ if (outcome.kind === 'ambiguous') {
2361
+ return {
2362
+ status: 'ambiguous',
2363
+ message: `Found ${outcome.candidates.length} symbols matching '${target}'. Re-call explain with the file path, or disambiguate via context() first.`,
2364
+ candidates: outcome.candidates.map((c) => ({
2365
+ uid: c.id,
2366
+ name: c.name,
2367
+ kind: c.type,
2368
+ filePath: c.filePath,
2369
+ line: c.startLine,
2370
+ score: Number(c.score.toFixed(2)),
2371
+ })),
2372
+ };
2373
+ }
2374
+ const sym = outcome.symbol;
2375
+ queryParams.idPrefix = `BasicBlock:${sym.filePath}:`;
2376
+ anchor = { file: sym.filePath, symbol: sym.name };
2377
+ if (typeof sym.startLine === 'number' &&
2378
+ typeof sym.endLine === 'number' &&
2379
+ sym.endLine >= sym.startLine) {
2380
+ anchorClause =
2381
+ 'AND a.id STARTS WITH $idPrefix AND a.startLine >= $symStart AND a.startLine <= $symEnd';
2382
+ queryParams.symStart = sym.startLine;
2383
+ queryParams.symEnd = sym.endLine;
2384
+ anchor.startLine = sym.startLine;
2385
+ anchor.endLine = sym.endLine;
2386
+ }
2387
+ else {
2388
+ // No usable span — degrade to the file-level filter (documented).
2389
+ anchorClause = 'AND a.id STARTS WITH $idPrefix';
2390
+ }
2391
+ return undefined;
2392
+ };
2393
+ // Bounded by construction: the BasicBlock→BasicBlock partition holds only
2394
+ // the sparse pdg layers, TAINTED rows are per-function-capped at analyze
2395
+ // time, and the page is LIMIT-bounded (the limit is a validated integer —
2396
+ // interpolated because LadybugDB does not parameterize LIMIT).
2397
+ const runAnchoredQuery = async () => {
2398
+ const matchClause = `
2399
+ MATCH (a:BasicBlock)-[r:CodeRelation]->(b:BasicBlock)
2400
+ WHERE r.type = 'TAINTED' ${anchorClause}`;
2401
+ const [qRows, countRows] = await Promise.all([
2402
+ executeParameterized(repo.lbugPath, `${matchClause}
2403
+ RETURN a.id AS sourceBlockId, a.filePath AS file, a.startLine AS sourceStart,
2404
+ b.startLine AS sinkStart, r.reason AS reason, b.id AS sinkBlockId
2405
+ ORDER BY sourceBlockId, sinkBlockId, reason
2406
+ LIMIT ${limit}`, queryParams),
2407
+ executeParameterized(repo.lbugPath, `${matchClause}
2408
+ RETURN COUNT(*) AS total`, queryParams),
2409
+ ]);
2410
+ return {
2411
+ rows: qRows,
2412
+ totalFindings: Number(countRows[0]?.total ?? countRows[0]?.[0] ?? 0),
2413
+ };
2414
+ };
2415
+ if (target) {
2416
+ if (looksLikeFilePath(target)) {
2417
+ buildFileAnchor();
2418
+ }
2419
+ else {
2420
+ // A bare or dotted symbol name (`UserController.create`) — resolve as a
2421
+ // symbol rather than silently file-anchoring to an empty result.
2422
+ const early = await resolveSymbolAnchor();
2423
+ if (early)
2424
+ return early;
2425
+ }
2426
+ }
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.
2431
+ return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
2432
+ }
2433
+ if (totalFindings === 0 && pdgStamped === undefined && target) {
2434
+ // Anchored miss with unreadable meta: one extra bounded probe decides
2435
+ // "no findings for this anchor" vs "no taint layer at all".
2436
+ 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) {
2438
+ return { findings: [], totalFindings: 0, note: NO_TAINT_NOTE };
2439
+ }
2440
+ }
2441
+ const findings = rows.map((r) => {
2442
+ const sourceBlockId = String(r.sourceBlockId ?? r[0] ?? '');
2443
+ const file = String(r.file ?? r[1] ?? '');
2444
+ const sourceStart = (r.sourceStart ?? r[2]);
2445
+ const sinkStart = (r.sinkStart ?? r[3]);
2446
+ const reason = r.reason ?? r[4];
2447
+ // basicBlockId = `BasicBlock:<filePath>:<fnLine>:<fnCol>:<blockIdx>` —
2448
+ // split from the RIGHT (the filePath may itself contain ':').
2449
+ const idParts = sourceBlockId.split(':');
2450
+ const fnLine = Number(idParts[idParts.length - 3]);
2451
+ const decoded = decodeTaintPath(reason);
2452
+ if (!decoded.ok) {
2453
+ // Unreadable reason (foreign/corrupt row): surface the finding's
2454
+ // existence with its block anchors, never throw.
2455
+ return {
2456
+ file,
2457
+ ...(Number.isInteger(fnLine) ? { functionLine: fnLine } : {}),
2458
+ sinkKind: 'unknown',
2459
+ source: { line: sourceStart },
2460
+ sink: { line: sinkStart },
2461
+ hops: [],
2462
+ pathIncomplete: true,
2463
+ };
2464
+ }
2465
+ const hops = decoded.hops.map((h) => ({
2466
+ variable: h.variable,
2467
+ line: h.line,
2468
+ ...(h.viaCall ? { viaCall: true } : {}),
2469
+ }));
2470
+ const first = hops[0];
2471
+ const last = hops[hops.length - 1];
2472
+ return {
2473
+ file,
2474
+ ...(Number.isInteger(fnLine) ? { functionLine: fnLine } : {}),
2475
+ sinkKind: decoded.kind ?? 'unknown',
2476
+ source: first ? { variable: first.variable, line: first.line } : { line: sourceStart },
2477
+ sink: { line: last?.line ?? sinkStart },
2478
+ hops,
2479
+ ...(decoded.truncated ? { pathIncomplete: true } : {}),
2480
+ };
2481
+ });
2482
+ return {
2483
+ ...(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.',
2488
+ };
2489
+ }
2250
2490
  /**
2251
2491
  * Legacy explore — kept for backwards compatibility with resources.ts.
2252
2492
  * Routes cluster/process types to direct graph queries.
@@ -270,6 +270,7 @@ async function getContextResource(backend, repoName) {
270
270
  lines.push(' - query: Process-grouped code intelligence (execution flows related to a concept)');
271
271
  lines.push(' - context: 360-degree symbol view (categorized refs, process participation)');
272
272
  lines.push(' - impact: Blast radius analysis (what breaks if you change a symbol)');
273
+ lines.push(' - explain: Persisted taint findings — source→sink data flows with per-hop variables (requires analyze --pdg)');
273
274
  lines.push(' - detect_changes: Git-diff impact analysis (what do your changes affect)');
274
275
  lines.push(' - rename: Multi-file coordinated rename with confidence tags');
275
276
  lines.push(' - cypher: Raw graph queries');
@@ -36,4 +36,13 @@ export interface ToolDefinition {
36
36
  */
37
37
  export declare const LIST_REPOS_DEFAULT_LIMIT = 50;
38
38
  export declare const LIST_REPOS_MAX_LIMIT = 200;
39
+ /**
40
+ * Pagination bounds for the `explain` tool (#2083 M3 U6). Findings are sparse
41
+ * and capped per function at analyze time, but a large repo can still
42
+ * accumulate enough TAINTED rows to blow MCP/LLM token limits — the response
43
+ * is page-bounded like `list_repos`. Exported so the backend clamp
44
+ * (`local-backend.ts`) and the schema stay a single source of truth.
45
+ */
46
+ export declare const EXPLAIN_DEFAULT_LIMIT = 50;
47
+ export declare const EXPLAIN_MAX_LIMIT = 200;
39
48
  export declare const GITNEXUS_TOOLS: ToolDefinition[];
package/dist/mcp/tools.js CHANGED
@@ -32,6 +32,15 @@ const DESTRUCTIVE_TOOL_ANNOTATIONS = {
32
32
  */
33
33
  export const LIST_REPOS_DEFAULT_LIMIT = 50;
34
34
  export const LIST_REPOS_MAX_LIMIT = 200;
35
+ /**
36
+ * Pagination bounds for the `explain` tool (#2083 M3 U6). Findings are sparse
37
+ * and capped per function at analyze time, but a large repo can still
38
+ * accumulate enough TAINTED rows to blow MCP/LLM token limits — the response
39
+ * is page-bounded like `list_repos`. Exported so the backend clamp
40
+ * (`local-backend.ts`) and the schema stay a single source of truth.
41
+ */
42
+ export const EXPLAIN_DEFAULT_LIMIT = 50;
43
+ export const EXPLAIN_MAX_LIMIT = 200;
35
44
  export const GITNEXUS_TOOLS = [
36
45
  {
37
46
  name: 'list_repos',
@@ -462,6 +471,49 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
462
471
  required: ['target', 'direction'],
463
472
  },
464
473
  },
474
+ {
475
+ name: 'explain',
476
+ description: `Explain persisted taint findings: intra-procedural source→sink data flows (TAINTED edges) recorded by \`gitnexus analyze --pdg\`.
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).
479
+
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
+
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).
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))).
488
+ - Property/field flows are not tracked (obj.x = taint; sink(obj.y) has no chain).
489
+ - Guard-style sanitizers (if (isValid(x))) and implicit/control-dependence flows are not modeled.
490
+ - CommonJS aliasing is partially modeled (require('<literal>') joins resolve; dynamic requires do not).
491
+ - Exception-path over-approximation can produce false-positive noise.
492
+
493
+ Findings are deliberately NOT part of impact()'s traversal or the web schema — explain is the dedicated taint consumer. SANITIZES (kill) edges are queryable via cypher.`,
494
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
495
+ inputSchema: {
496
+ type: 'object',
497
+ properties: {
498
+ target: {
499
+ type: 'string',
500
+ description: 'Optional anchor: a file path (e.g. "src/handlers/run.ts" — suffix match accepted) or a symbol/function name (resolved like context()). Omit to enumerate all findings for the repo.',
501
+ },
502
+ limit: {
503
+ type: 'integer',
504
+ description: `Max findings returned (default: ${EXPLAIN_DEFAULT_LIMIT}, max: ${EXPLAIN_MAX_LIMIT}). "totalFindings" reports the full matched count; "truncated" is set when the page is smaller.`,
505
+ default: EXPLAIN_DEFAULT_LIMIT,
506
+ minimum: 1,
507
+ maximum: EXPLAIN_MAX_LIMIT,
508
+ },
509
+ repo: {
510
+ type: 'string',
511
+ description: 'Repository name or path. Omit if only one repo is indexed.',
512
+ },
513
+ },
514
+ required: [],
515
+ },
516
+ },
465
517
  {
466
518
  name: 'route_map',
467
519
  description: `Show API route mappings: which components/hooks fetch which API endpoints, and which handler files serve them.
@@ -595,6 +647,7 @@ const BRANCH_SCOPED_TOOLS = new Set([
595
647
  'cypher',
596
648
  'context',
597
649
  'detect_changes',
650
+ 'explain',
598
651
  'check',
599
652
  'impact',
600
653
  'rename',
@@ -111,7 +111,16 @@ export const computeChunkHash = (entries, pdg = false) => {
111
111
  // option-blind-key trap. `def` marks an unset (default) value so two
112
112
  // default-cap runs share a key. The emit-time edge cap is deliberately
113
113
  // absent — see the PdgCacheKey doc comment.
114
- const ns = `pdg:1;maxFn=${opts.maxFunctionLines ?? 'def'}`;
114
+ //
115
+ // NAMESPACE VERSION (`pdg:2`): bumped when the worker-emitted
116
+ // `cfgSideChannel` SHAPE changes for pdg-mode runs only — pdg:1→2 in #2083
117
+ // M3 U1 (TsHarvester emits taint `sites` on StatementFacts). Invalidates
118
+ // pdg-mode chunks and their durable parsedfile-cache entries; flag-off
119
+ // chunk keys never reach this line and stay byte-identical, so non-pdg
120
+ // users pay nothing. Deliberately NOT a SCHEMA_BUMP — that gates the whole
121
+ // cache version and would force a full cold re-parse on EVERY user (the M1
122
+ // bump comment above records that cost).
123
+ const ns = `pdg:2;maxFn=${opts.maxFunctionLines ?? 'def'}`;
115
124
  return sha256Hex(`${ns}\n${joined}`);
116
125
  };
117
126
  /**
@@ -134,6 +134,24 @@ export interface RepoMeta {
134
134
  * type for that reason; resolved (always present) on every M2+ write.
135
135
  */
136
136
  maxReachingDefEdgesPerFunction?: number;
137
+ /**
138
+ * Per-function taint findings cap, resolved (0 = unlimited; #2083 M3).
139
+ * ABSENT on an M1/M2-era stamp — like `maxReachingDefEdgesPerFunction`,
140
+ * that absence is what trips `pdgModeMismatch` on the first M3 run and
141
+ * forces the full writeback that populates TAINTED/SANITIZES rows.
142
+ */
143
+ maxTaintFindingsPerFunction?: number;
144
+ /** Per-finding taint hop cap, resolved (0 = unlimited; #2083 M3 KTD6 —
145
+ * bounds the persisted hop-encoded `reason`). Optional for the same
146
+ * M2-era-stamp upgrade reason as the findings cap. */
147
+ maxTaintHops?: number;
148
+ /**
149
+ * Digest of the built-in taint model the persisted findings were
150
+ * produced under (#2083 M3 KTD7/R7). Any model-content change ships a
151
+ * new digest → mismatch → full writeback repopulates taint edges
152
+ * without `--force`. Optional: absent on pre-M3 stamps.
153
+ */
154
+ taintModelVersion?: string;
137
155
  };
138
156
  }
139
157
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.8-rc.20",
3
+ "version": "1.6.8-rc.21",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -38,6 +38,7 @@ For any task involving code understanding, debugging, impact analysis, or refact
38
38
  | `detect_changes` | Git-diff impact — what do your current changes affect |
39
39
  | `rename` | Multi-file coordinated rename with confidence-tagged edits |
40
40
  | `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
41
+ | `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) |
41
42
  | `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) |
42
43
 
43
44
  ### Paginating `list_repos`
@@ -71,6 +72,16 @@ list_repos { offset: 400 } → repos 401–437, hasMore false
71
72
 
72
73
  Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged.
73
74
 
75
+ ### Taint findings (`explain`)
76
+
77
+ `explain` returns intra-procedural taint findings (`TAINTED` edges) recorded by `gitnexus analyze --pdg` — each with a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop.
78
+
79
+ - `explain {}` — enumerate all findings for the repo (bounded by `limit`, deterministic order)
80
+ - `explain { target: "src/vuln.ts" }` — findings in a file (suffix path match accepted)
81
+ - `explain { target: "runUserCommand" }` — findings in a function (resolved like `context`; ambiguous names return ranked candidates)
82
+
83
+ A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: findings are intra-procedural only — cross-function, closure/callback, property/field, and implicit flows are not modeled, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`.
84
+
74
85
  ## Resources Reference
75
86
 
76
87
  Lightweight reads (~100-500 tokens) for navigation: