gitnexus 1.6.8-rc.2 → 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 (193) hide show
  1. package/README.md +20 -0
  2. package/dist/_shared/scope-resolution/parsed-file.d.ts +21 -0
  3. package/dist/_shared/scope-resolution/parsed-file.d.ts.map +1 -1
  4. package/dist/_shared/scope-resolution/symbol-definition.d.ts +4 -0
  5. package/dist/_shared/scope-resolution/symbol-definition.d.ts.map +1 -1
  6. package/dist/cli/ai-context.js +1 -0
  7. package/dist/cli/analyze-config.js +40 -0
  8. package/dist/cli/analyze.d.ts +22 -0
  9. package/dist/cli/analyze.js +36 -6
  10. package/dist/cli/clean.d.ts +1 -0
  11. package/dist/cli/clean.js +43 -1
  12. package/dist/cli/eval-server.js +43 -0
  13. package/dist/cli/help-i18n.js +11 -0
  14. package/dist/cli/i18n/en.d.ts +13 -0
  15. package/dist/cli/i18n/en.js +13 -0
  16. package/dist/cli/i18n/resources.d.ts +26 -0
  17. package/dist/cli/i18n/zh-CN.d.ts +13 -0
  18. package/dist/cli/i18n/zh-CN.js +13 -0
  19. package/dist/cli/index.js +19 -0
  20. package/dist/cli/list.js +12 -0
  21. package/dist/cli/optional-grammars.d.ts +6 -8
  22. package/dist/cli/optional-grammars.js +8 -11
  23. package/dist/cli/skill-gen.js +1 -0
  24. package/dist/cli/status.js +26 -5
  25. package/dist/cli/tool.d.ts +12 -1
  26. package/dist/cli/tool.js +41 -1
  27. package/dist/core/embeddings/embedder.js +4 -0
  28. package/dist/core/embeddings/embedding-pipeline.js +27 -16
  29. package/dist/core/embeddings/onnxruntime-common-resolver.d.ts +6 -0
  30. package/dist/core/embeddings/onnxruntime-common-resolver.js +130 -0
  31. package/dist/core/graph/import-cycles.d.ts +10 -0
  32. package/dist/core/graph/import-cycles.js +103 -0
  33. package/dist/core/group/extractors/grpc-patterns/proto.js +10 -6
  34. package/dist/core/group/extractors/http-patterns/java.js +3 -50
  35. package/dist/core/group/extractors/http-patterns/kotlin.js +7 -7
  36. package/dist/core/group/extractors/include-extractor.js +7 -7
  37. package/dist/core/ingestion/cfg/cfg-builder.d.ts +64 -0
  38. package/dist/core/ingestion/cfg/cfg-builder.js +129 -0
  39. package/dist/core/ingestion/cfg/collect.d.ts +30 -0
  40. package/dist/core/ingestion/cfg/collect.js +34 -0
  41. package/dist/core/ingestion/cfg/control-flow-context.d.ts +97 -0
  42. package/dist/core/ingestion/cfg/control-flow-context.js +113 -0
  43. package/dist/core/ingestion/cfg/emit.d.ts +144 -0
  44. package/dist/core/ingestion/cfg/emit.js +315 -0
  45. package/dist/core/ingestion/cfg/reaching-defs.d.ts +90 -0
  46. package/dist/core/ingestion/cfg/reaching-defs.js +364 -0
  47. package/dist/core/ingestion/cfg/traversal-result.d.ts +20 -0
  48. package/dist/core/ingestion/cfg/traversal-result.js +2 -0
  49. package/dist/core/ingestion/cfg/types.d.ts +231 -0
  50. package/dist/core/ingestion/cfg/types.js +13 -0
  51. package/dist/core/ingestion/cfg/visitors/typescript-harvest.d.ts +144 -0
  52. package/dist/core/ingestion/cfg/visitors/typescript-harvest.js +1032 -0
  53. package/dist/core/ingestion/cfg/visitors/typescript.d.ts +66 -0
  54. package/dist/core/ingestion/cfg/visitors/typescript.js +584 -0
  55. package/dist/core/ingestion/language-provider.d.ts +36 -2
  56. package/dist/core/ingestion/languages/c-cpp.js +11 -4
  57. package/dist/core/ingestion/languages/cpp/arity-metadata.js +6 -2
  58. package/dist/core/ingestion/languages/cpp/captures.js +24 -1
  59. package/dist/core/ingestion/languages/cpp/query.js +23 -0
  60. package/dist/core/ingestion/languages/java.js +3 -0
  61. package/dist/core/ingestion/languages/kotlin/query.js +3 -2
  62. package/dist/core/ingestion/languages/kotlin.js +5 -1
  63. package/dist/core/ingestion/languages/typescript.js +5 -0
  64. package/dist/core/ingestion/method-extractors/configs/c-cpp.js +6 -13
  65. package/dist/core/ingestion/method-extractors/generic.js +1 -0
  66. package/dist/core/ingestion/method-types.d.ts +2 -0
  67. package/dist/core/ingestion/model/symbol-table.d.ts +1 -0
  68. package/dist/core/ingestion/model/symbol-table.js +1 -0
  69. package/dist/core/ingestion/parsing-processor.js +22 -0
  70. package/dist/core/ingestion/pipeline-phases/parse-impl.js +18 -1
  71. package/dist/core/ingestion/pipeline-phases/routes.js +64 -14
  72. package/dist/core/ingestion/pipeline.d.ts +57 -0
  73. package/dist/core/ingestion/route-extractors/spring-shared.d.ts +50 -0
  74. package/dist/core/ingestion/route-extractors/spring-shared.js +80 -0
  75. package/dist/core/ingestion/route-extractors/spring.d.ts +35 -0
  76. package/dist/core/ingestion/route-extractors/spring.js +136 -0
  77. package/dist/core/ingestion/scope-extractor.js +3 -0
  78. package/dist/core/ingestion/scope-resolution/passes/free-call-fallback.js +12 -0
  79. package/dist/core/ingestion/scope-resolution/passes/receiver-bound-calls.js +145 -24
  80. package/dist/core/ingestion/scope-resolution/pipeline/phase.js +6 -0
  81. package/dist/core/ingestion/scope-resolution/pipeline/reconcile-ownership.js +43 -3
  82. package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +21 -0
  83. package/dist/core/ingestion/scope-resolution/pipeline/run.js +207 -0
  84. package/dist/core/ingestion/scope-resolution/resolution-outcome.d.ts +1 -1
  85. package/dist/core/ingestion/taint/emit.d.ts +124 -0
  86. package/dist/core/ingestion/taint/emit.js +204 -0
  87. package/dist/core/ingestion/taint/match.d.ts +153 -0
  88. package/dist/core/ingestion/taint/match.js +278 -0
  89. package/dist/core/ingestion/taint/path-codec.d.ts +134 -0
  90. package/dist/core/ingestion/taint/path-codec.js +190 -0
  91. package/dist/core/ingestion/taint/propagate.d.ts +216 -0
  92. package/dist/core/ingestion/taint/propagate.js +664 -0
  93. package/dist/core/ingestion/taint/site-safety.d.ts +29 -0
  94. package/dist/core/ingestion/taint/site-safety.js +98 -0
  95. package/dist/core/ingestion/taint/source-sink-config.d.ts +94 -23
  96. package/dist/core/ingestion/taint/source-sink-config.js +11 -11
  97. package/dist/core/ingestion/taint/source-sink-registry.d.ts +6 -4
  98. package/dist/core/ingestion/taint/source-sink-registry.js +6 -4
  99. package/dist/core/ingestion/taint/typescript-model.d.ts +38 -0
  100. package/dist/core/ingestion/taint/typescript-model.js +102 -0
  101. package/dist/core/ingestion/utils/method-props.js +1 -0
  102. package/dist/core/ingestion/workers/clone-safety.d.ts +109 -0
  103. package/dist/core/ingestion/workers/clone-safety.js +465 -0
  104. package/dist/core/ingestion/workers/parse-worker.d.ts +11 -0
  105. package/dist/core/ingestion/workers/parse-worker.js +70 -53
  106. package/dist/core/ingestion/workers/post-result.d.ts +22 -0
  107. package/dist/core/ingestion/workers/post-result.js +87 -0
  108. package/dist/core/ingestion/workers/result-merge.d.ts +20 -0
  109. package/dist/core/ingestion/workers/result-merge.js +43 -0
  110. package/dist/core/ingestion/workers/worker-pool.d.ts +15 -0
  111. package/dist/core/ingestion/workers/worker-pool.js +33 -16
  112. package/dist/core/lbug/lbug-adapter.d.ts +19 -0
  113. package/dist/core/lbug/lbug-adapter.js +56 -1
  114. package/dist/core/run-analyze.d.ts +87 -0
  115. package/dist/core/run-analyze.js +280 -25
  116. package/dist/core/tree-sitter/parser-loader.js +5 -4
  117. package/dist/core/tree-sitter/vendored-grammars.d.ts +39 -0
  118. package/dist/core/tree-sitter/vendored-grammars.js +57 -0
  119. package/dist/mcp/core/embedder.js +4 -0
  120. package/dist/mcp/local/local-backend.d.ts +86 -2
  121. package/dist/mcp/local/local-backend.js +657 -27
  122. package/dist/mcp/resources.js +1 -0
  123. package/dist/mcp/tools.d.ts +9 -0
  124. package/dist/mcp/tools.js +109 -0
  125. package/dist/server/analyze-launch.d.ts +29 -0
  126. package/dist/server/analyze-launch.js +137 -0
  127. package/dist/server/analyze-upload.d.ts +33 -0
  128. package/dist/server/analyze-upload.js +123 -0
  129. package/dist/server/analyze-worker-ipc.d.ts +58 -0
  130. package/dist/server/analyze-worker-ipc.js +16 -0
  131. package/dist/server/analyze-worker.d.ts +17 -1
  132. package/dist/server/analyze-worker.js +7 -1
  133. package/dist/server/api.js +46 -140
  134. package/dist/server/git-clone.d.ts +1 -0
  135. package/dist/server/git-clone.js +1 -1
  136. package/dist/server/middleware.d.ts +11 -0
  137. package/dist/server/middleware.js +27 -0
  138. package/dist/server/upload-ingest.d.ts +56 -0
  139. package/dist/server/upload-ingest.js +276 -0
  140. package/dist/server/upload-paths.d.ts +31 -0
  141. package/dist/server/upload-paths.js +51 -0
  142. package/dist/server/upload-sweep.d.ts +20 -0
  143. package/dist/server/upload-sweep.js +57 -0
  144. package/dist/storage/branch-index.d.ts +52 -0
  145. package/dist/storage/branch-index.js +65 -0
  146. package/dist/storage/git.d.ts +11 -0
  147. package/dist/storage/git.js +28 -0
  148. package/dist/storage/parse-cache.d.ts +22 -1
  149. package/dist/storage/parse-cache.js +32 -10
  150. package/dist/storage/repo-manager.d.ts +122 -10
  151. package/dist/storage/repo-manager.js +162 -21
  152. package/hooks/antigravity/gitnexus-antigravity-hook.cjs +42 -8
  153. package/hooks/claude/gitnexus-hook.cjs +36 -8
  154. package/hooks/claude/hook-db-lock-probe.cjs +123 -2
  155. package/package.json +4 -2
  156. package/scripts/assert-publish-grammar-coverage.cjs +31 -0
  157. package/scripts/build-tree-sitter-grammars.cjs +16 -10
  158. package/skills/gitnexus-guide.md +11 -0
  159. package/vendor/tree-sitter-c/package.json +1 -1
  160. package/vendor/tree-sitter-dart/package.json +1 -1
  161. package/vendor/tree-sitter-kotlin/package.json +1 -1
  162. package/vendor/tree-sitter-proto/package.json +1 -1
  163. package/vendor/tree-sitter-swift/package.json +1 -1
  164. package/web/assets/{agent-CKPMqImC.js → agent-ay4LD70X.js} +1 -1
  165. package/web/assets/{architectureDiagram-UL44E2DR-D1EXI0zA.js → architectureDiagram-UL44E2DR-Dc-viYhd.js} +1 -1
  166. package/web/assets/{chunk-LCXTWHL2-Dfmux4m1.js → chunk-LCXTWHL2-4rpojOyj.js} +1 -1
  167. package/web/assets/{chunk-RG4AUYOV-CAkzcoRj.js → chunk-RG4AUYOV-BnOy944n.js} +1 -1
  168. package/web/assets/{classDiagram-KGZ6W3CR-Bv93af_b.js → classDiagram-KGZ6W3CR-Bsgpy98Q.js} +1 -1
  169. package/web/assets/{classDiagram-v2-72OJOZXJ-CLfEqHUa.js → classDiagram-v2-72OJOZXJ-D5atDGjc.js} +1 -1
  170. package/web/assets/{diagram-3NCE3AQN-BqAtKUpW.js → diagram-3NCE3AQN-Cz1OEMVi.js} +1 -1
  171. package/web/assets/{diagram-GF46GFSD-CaBG6n6o.js → diagram-GF46GFSD-CWYwzfP8.js} +1 -1
  172. package/web/assets/{diagram-QXG6HAR7-CZ-O3rcV.js → diagram-QXG6HAR7-DtBObr8L.js} +1 -1
  173. package/web/assets/{diagram-WEQXMOUZ-P4lSL4GH.js → diagram-WEQXMOUZ-BUV44Ov_.js} +1 -1
  174. package/web/assets/{erDiagram-L5TCEMPS-QN2eEP1e.js → erDiagram-L5TCEMPS-Ds9s-sRF.js} +1 -1
  175. package/web/assets/{flowDiagram-H6V6AXG4-CXbXImlN.js → flowDiagram-H6V6AXG4-CupStHQb.js} +1 -1
  176. package/web/assets/index-COMMmbxW.css +2 -0
  177. package/web/assets/{index-CG6q8eTs.js → index-Do8AE5yF.js} +85 -85
  178. package/web/assets/{infoDiagram-3YFTVSEB-DmLICZx1.js → infoDiagram-3YFTVSEB-DPTXSbgu.js} +1 -1
  179. package/web/assets/{ishikawaDiagram-BNXS4ZKH-6LKOvBfp.js → ishikawaDiagram-BNXS4ZKH-CeK-Qhet.js} +1 -1
  180. package/web/assets/{kanban-definition-75IXJCU3-DgDi9oJT.js → kanban-definition-75IXJCU3-D4tx6QQ-.js} +1 -1
  181. package/web/assets/{mindmap-definition-2TDM6QVE-CwR5sBB-.js → mindmap-definition-2TDM6QVE-Bcto_kGa.js} +1 -1
  182. package/web/assets/{pieDiagram-CU6KROY3-By8g6f6B.js → pieDiagram-CU6KROY3-DPIeBTG6.js} +1 -1
  183. package/web/assets/{requirementDiagram-JXO7QTGE-7oDcJ1_J.js → requirementDiagram-JXO7QTGE-DDGXjVKO.js} +1 -1
  184. package/web/assets/{sequenceDiagram-VS2MUI6T-FySeKCUy.js → sequenceDiagram-VS2MUI6T-BzDqpcVW.js} +1 -1
  185. package/web/assets/{stateDiagram-7D4R322I-CB2nABwH.js → stateDiagram-7D4R322I-Bx__als3.js} +1 -1
  186. package/web/assets/{stateDiagram-v2-36443NZ5-COBGd2RL.js → stateDiagram-v2-36443NZ5-QqOh2yO-.js} +1 -1
  187. package/web/assets/{timeline-definition-O6YCAMPW-Ds2CnVZK.js → timeline-definition-O6YCAMPW-C1eogTOG.js} +1 -1
  188. package/web/assets/{vennDiagram-MWXL3ELB-DUIEwXWp.js → vennDiagram-MWXL3ELB-D20F4rSW.js} +1 -1
  189. package/web/assets/{wardleyDiagram-CUQ6CDDI-DEiFPQih.js → wardleyDiagram-CUQ6CDDI-BeBHg7ST.js} +1 -1
  190. package/web/assets/{xychartDiagram-N2JHSOCM-BUbayhST.js → xychartDiagram-N2JHSOCM-CdOWeoNI.js} +1 -1
  191. package/web/index.html +2 -2
  192. package/scripts/materialize-vendor-grammars.cjs +0 -97
  193. package/web/assets/index-BKWA-m7o.css +0 -2
@@ -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',
@@ -277,6 +286,29 @@ Returns: changed symbols, affected processes, and a risk summary.`,
277
286
  required: [],
278
287
  },
279
288
  },
289
+ {
290
+ name: 'check',
291
+ description: `Run read-only structural checks against the indexed graph.
292
+
293
+ Currently detects directed cycles between File nodes connected by IMPORTS edges.
294
+ Returns deterministic cycle paths and a cycle count suitable for CI automation.`,
295
+ annotations: READ_ONLY_TOOL_ANNOTATIONS,
296
+ inputSchema: {
297
+ type: 'object',
298
+ properties: {
299
+ cycles: {
300
+ type: 'boolean',
301
+ description: 'Detect circular file imports (default: true).',
302
+ default: true,
303
+ },
304
+ repo: {
305
+ type: 'string',
306
+ description: 'Repository name or path. Omit if only one repo is indexed.',
307
+ },
308
+ },
309
+ required: [],
310
+ },
311
+ },
280
312
  {
281
313
  name: 'rename',
282
314
  description: `Multi-file coordinated rename using the knowledge graph + text search.
@@ -439,6 +471,49 @@ SERVICE: optional monorepo path prefix (case-sensitive path segments). When "rep
439
471
  required: ['target', 'direction'],
440
472
  },
441
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
+ },
442
517
  {
443
518
  name: 'route_map',
444
519
  description: `Show API route mappings: which components/hooks fetch which API endpoints, and which handler files serve them.
@@ -560,3 +635,37 @@ WHEN TO USE: After changing group.yaml or re-indexing member repos.`,
560
635
  },
561
636
  },
562
637
  ];
638
+ /**
639
+ * Per-repo tools that accept an optional `branch` scope (#2106). Single source
640
+ * of truth: the schema property is injected here so it cannot drift from the
641
+ * server-side default in `local-backend.ts` (`resolveRepo(repo, branch)`).
642
+ * `list_repos` and the `group_*` tools are intentionally excluded — they are
643
+ * not single-repo, single-branch operations.
644
+ */
645
+ const BRANCH_SCOPED_TOOLS = new Set([
646
+ 'query',
647
+ 'cypher',
648
+ 'context',
649
+ 'detect_changes',
650
+ 'explain',
651
+ 'check',
652
+ 'impact',
653
+ 'rename',
654
+ 'route_map',
655
+ 'tool_map',
656
+ 'shape_check',
657
+ 'api_impact',
658
+ ]);
659
+ for (const tool of GITNEXUS_TOOLS) {
660
+ if (!BRANCH_SCOPED_TOOLS.has(tool.name))
661
+ continue;
662
+ if (tool.inputSchema.properties.branch)
663
+ continue;
664
+ // Optional — `required` is left unchanged so omitting `branch` keeps today's
665
+ // default/primary-branch behavior. Ignored in group mode (repo starts "@").
666
+ tool.inputSchema.properties.branch = {
667
+ type: 'string',
668
+ description: 'Optional: scope to a specific branch index (multi-branch repos, #2106). ' +
669
+ 'Omit for the default/primary branch. Ignored in group mode.',
670
+ };
671
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Shared analyze-worker launcher.
3
+ *
4
+ * Forks the analyze worker for an already-resolved repo directory and owns the
5
+ * lock + auto-retry + IPC machinery. Used by both the JSON `/api/analyze` route
6
+ * and the multipart `/api/analyze/upload` route. Dependency-injected (like
7
+ * createAnalyzeUploadHandler) so the seam is testable and api.ts stays smaller.
8
+ *
9
+ * NOTE: this module must live alongside analyze-worker.{ts,js} — the worker
10
+ * path is resolved relative to `import.meta.url`.
11
+ */
12
+ import type { JobManager } from './analyze-job.js';
13
+ export interface LaunchDeps {
14
+ jobManager: JobManager;
15
+ backend: {
16
+ init: () => Promise<unknown>;
17
+ };
18
+ acquireRepoLock: (key: string) => string | null;
19
+ releaseRepoLock: (key: string) => void;
20
+ }
21
+ export interface LaunchOptions {
22
+ force?: boolean;
23
+ embeddings?: boolean;
24
+ dropEmbeddings?: boolean;
25
+ registryName?: string;
26
+ }
27
+ export declare function createLaunchAnalysisWorker(deps: LaunchDeps): (job: {
28
+ id: string;
29
+ }, targetPath: string, opts: LaunchOptions) => void;
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Shared analyze-worker launcher.
3
+ *
4
+ * Forks the analyze worker for an already-resolved repo directory and owns the
5
+ * lock + auto-retry + IPC machinery. Used by both the JSON `/api/analyze` route
6
+ * and the multipart `/api/analyze/upload` route. Dependency-injected (like
7
+ * createAnalyzeUploadHandler) so the seam is testable and api.ts stays smaller.
8
+ *
9
+ * NOTE: this module must live alongside analyze-worker.{ts,js} — the worker
10
+ * path is resolved relative to `import.meta.url`.
11
+ */
12
+ import path from 'path';
13
+ import { fork } from 'child_process';
14
+ import { fileURLToPath, pathToFileURL } from 'url';
15
+ import { createRequire } from 'node:module';
16
+ import { getStoragePath } from '../storage/repo-manager.js';
17
+ import { logger } from '../core/logger.js';
18
+ const _require = createRequire(import.meta.url);
19
+ const MAX_WORKER_RETRIES = 2;
20
+ export function createLaunchAnalysisWorker(deps) {
21
+ const { jobManager, backend, acquireRepoLock, releaseRepoLock } = deps;
22
+ return function launchAnalysisWorker(job, targetPath, opts) {
23
+ // Acquire shared repo lock (keyed on storagePath to match embed handler)
24
+ const analyzeLockKey = getStoragePath(targetPath);
25
+ const lockErr = acquireRepoLock(analyzeLockKey);
26
+ if (lockErr) {
27
+ jobManager.updateJob(job.id, { status: 'failed', error: lockErr });
28
+ return;
29
+ }
30
+ jobManager.updateJob(job.id, { repoPath: targetPath, status: 'analyzing' });
31
+ // ── Worker fork with auto-retry ──────────────────────────────
32
+ const callerPath = fileURLToPath(import.meta.url);
33
+ const isDev = callerPath.endsWith('.ts');
34
+ const workerFile = isDev ? 'analyze-worker.ts' : 'analyze-worker.js';
35
+ const workerPath = path.join(path.dirname(callerPath), workerFile);
36
+ const tsxHookArgs = isDev
37
+ ? ['--import', pathToFileURL(_require.resolve('tsx/esm')).href]
38
+ : [];
39
+ const forkWorker = () => {
40
+ const currentJob = jobManager.getJob(job.id);
41
+ if (!currentJob || currentJob.status === 'complete' || currentJob.status === 'failed')
42
+ return;
43
+ const child = fork(workerPath, [], {
44
+ execArgv: [...tsxHookArgs, '--max-old-space-size=8192'],
45
+ stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
46
+ });
47
+ // Capture stderr for crash diagnostics
48
+ let stderrChunks = '';
49
+ child.stderr?.on('data', (chunk) => {
50
+ stderrChunks += chunk.toString();
51
+ if (stderrChunks.length > 4096)
52
+ stderrChunks = stderrChunks.slice(-4096);
53
+ });
54
+ child.on('message', (msg) => {
55
+ if (msg.type === 'progress') {
56
+ jobManager.updateJob(job.id, {
57
+ status: 'analyzing',
58
+ progress: { phase: msg.phase, percent: msg.percent, message: msg.message },
59
+ });
60
+ }
61
+ else if (msg.type === 'complete') {
62
+ releaseRepoLock(analyzeLockKey);
63
+ // Reinitialize backend BEFORE marking complete — ensures the new repo
64
+ // is queryable when the client receives the SSE complete event.
65
+ backend
66
+ .init()
67
+ .then(() => {
68
+ jobManager.updateJob(job.id, { status: 'complete', repoName: msg.result.repoName });
69
+ })
70
+ .catch((err) => {
71
+ logger.error({ err }, 'backend.init() failed after analyze:');
72
+ jobManager.updateJob(job.id, {
73
+ status: 'failed',
74
+ error: 'Server failed to reload after analysis. Try again.',
75
+ });
76
+ });
77
+ }
78
+ else if (msg.type === 'error') {
79
+ releaseRepoLock(analyzeLockKey);
80
+ jobManager.updateJob(job.id, { status: 'failed', error: msg.message });
81
+ }
82
+ });
83
+ child.on('error', (err) => {
84
+ releaseRepoLock(analyzeLockKey);
85
+ jobManager.updateJob(job.id, {
86
+ status: 'failed',
87
+ error: `Worker process error: ${err.message}`,
88
+ });
89
+ });
90
+ child.on('exit', (code) => {
91
+ const j = jobManager.getJob(job.id);
92
+ if (!j || j.status === 'complete' || j.status === 'failed')
93
+ return;
94
+ // Worker crashed — attempt retry if under the limit
95
+ if (j.retryCount < MAX_WORKER_RETRIES) {
96
+ j.retryCount++;
97
+ const delay = 1000 * Math.pow(2, j.retryCount - 1); // 1s, 2s
98
+ const lastErr = stderrChunks.trim().split('\n').pop() || '';
99
+ logger.warn(`Analyze worker crashed (code ${code}), retry ${j.retryCount}/${MAX_WORKER_RETRIES} in ${delay}ms` +
100
+ (lastErr ? `: ${lastErr}` : ''));
101
+ jobManager.updateJob(job.id, {
102
+ status: 'analyzing',
103
+ progress: {
104
+ phase: 'retrying',
105
+ percent: j.progress.percent,
106
+ message: `Worker crashed, retrying (${j.retryCount}/${MAX_WORKER_RETRIES})...`,
107
+ },
108
+ });
109
+ stderrChunks = '';
110
+ setTimeout(forkWorker, delay);
111
+ }
112
+ else {
113
+ // Exhausted retries — permanent failure
114
+ releaseRepoLock(analyzeLockKey);
115
+ jobManager.updateJob(job.id, {
116
+ status: 'failed',
117
+ error: `Worker crashed ${MAX_WORKER_RETRIES + 1} times (code ${code})${stderrChunks ? ': ' + stderrChunks.trim().split('\n').pop() : ''}`,
118
+ });
119
+ }
120
+ });
121
+ // Register child for cancellation + timeout tracking
122
+ jobManager.registerChild(job.id, child);
123
+ // Send start command to child
124
+ child.send({
125
+ type: 'start',
126
+ repoPath: targetPath,
127
+ options: {
128
+ force: !!opts.force,
129
+ embeddings: !!opts.embeddings,
130
+ dropEmbeddings: !!opts.dropEmbeddings,
131
+ ...(opts.registryName ? { registryName: opts.registryName } : {}),
132
+ },
133
+ });
134
+ };
135
+ forkWorker();
136
+ };
137
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * POST /api/analyze/upload — analyze a browser folder upload.
3
+ *
4
+ * Securely ingests the multipart upload into a sandbox (upload-ingest.ts),
5
+ * promotes it to a persistent app-controlled directory, and analyzes it via
6
+ * the same job/worker machinery as a git clone — never returning a server
7
+ * path to the client. Factored as a dependency-injected handler so the job
8
+ * machinery (createJob + the worker launcher) can be mocked in unit tests.
9
+ */
10
+ import type { Request, Response } from 'express';
11
+ import { ingestUpload } from './upload-ingest.js';
12
+ import type { AnalyzeJob } from './analyze-job.js';
13
+ /** Minimal job shape the handler needs (a subset of the real AnalyzeJob). */
14
+ export type UploadJobRef = Pick<AnalyzeJob, 'id' | 'status'>;
15
+ export interface AnalyzeUploadDeps {
16
+ /** Create (or throw on busy) an analysis job for the given upload dir. */
17
+ createJob: (params: {
18
+ repoPath: string;
19
+ }) => UploadJobRef;
20
+ /** Launch the analyze worker against an already-resolved repo directory. */
21
+ launch: (job: UploadJobRef, targetPath: string, opts: {
22
+ registryName: string;
23
+ }) => void;
24
+ /**
25
+ * Mark a created job failed. The job occupies the single analysis slot from
26
+ * createJob onward, so ANY error before launch must release it — otherwise a
27
+ * leaked non-terminal job wedges all future analyses until restart.
28
+ */
29
+ failJob: (jobId: string, error: string) => void;
30
+ /** Injectable for tests (defaults to the real ingestUpload). */
31
+ ingest?: typeof ingestUpload;
32
+ }
33
+ export declare function createAnalyzeUploadHandler(deps: AnalyzeUploadDeps): (req: Request, res: Response) => Promise<void>;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * POST /api/analyze/upload — analyze a browser folder upload.
3
+ *
4
+ * Securely ingests the multipart upload into a sandbox (upload-ingest.ts),
5
+ * promotes it to a persistent app-controlled directory, and analyzes it via
6
+ * the same job/worker machinery as a git clone — never returning a server
7
+ * path to the client. Factored as a dependency-injected handler so the job
8
+ * machinery (createJob + the worker launcher) can be mocked in unit tests.
9
+ */
10
+ import path from 'path';
11
+ import fsp from 'fs/promises';
12
+ import { ingestUpload } from './upload-ingest.js';
13
+ import { UPLOAD_ROOT, getUploadDir, deriveUploadName } from './upload-paths.js';
14
+ import { BadRequestError } from './validation.js';
15
+ /** Cap on collision-suffix attempts when allocating an upload dir name. */
16
+ const MAX_NAME_COLLISION_TRIES = 100;
17
+ /**
18
+ * Find an available upload directory name, appending `-2`, `-3`, … on
19
+ * collision with an existing upload. Bounded to avoid an unbounded scan.
20
+ */
21
+ async function pickAvailableName(base) {
22
+ for (let i = 0; i < MAX_NAME_COLLISION_TRIES; i++) {
23
+ const name = i === 0 ? base : `${base}-${i + 1}`;
24
+ let dir;
25
+ try {
26
+ dir = getUploadDir(name);
27
+ }
28
+ catch {
29
+ continue;
30
+ }
31
+ try {
32
+ await fsp.access(dir);
33
+ // exists → try the next suffix
34
+ }
35
+ catch {
36
+ return name; // ENOENT → available
37
+ }
38
+ }
39
+ throw new BadRequestError(`Could not allocate an upload directory after ${MAX_NAME_COLLISION_TRIES} attempts`, 409);
40
+ }
41
+ export function createAnalyzeUploadHandler(deps) {
42
+ const ingest = deps.ingest ?? ingestUpload;
43
+ return async function handleAnalyzeUploadRequest(req, res) {
44
+ let stageRoot;
45
+ let promotedDir;
46
+ let createdJobId;
47
+ let launched = false;
48
+ try {
49
+ const result = await ingest(req);
50
+ stageRoot = result.stageRoot;
51
+ const baseName = deriveUploadName(result.topLevelName);
52
+ if (!baseName) {
53
+ throw new BadRequestError('Uploaded folder has no usable name');
54
+ }
55
+ // webkitRelativePath prefixes every entry with the picked folder, so the
56
+ // real repo root is stageRoot/<topLevelName>. Validate it is a directory
57
+ // BEFORE taking the single analysis slot — a malformed (non-folder)
58
+ // upload must not be able to occupy the slot.
59
+ const innerRoot = path.join(result.stageRoot, result.topLevelName);
60
+ let innerIsDir = false;
61
+ try {
62
+ innerIsDir = (await fsp.stat(innerRoot)).isDirectory();
63
+ }
64
+ catch {
65
+ innerIsDir = false;
66
+ }
67
+ if (!innerIsDir) {
68
+ throw new BadRequestError('Upload must be a folder');
69
+ }
70
+ const finalName = await pickAvailableName(baseName);
71
+ const finalDir = getUploadDir(finalName);
72
+ // createJob occupies the single analysis slot (throws 'already in
73
+ // progress' → 409). From here on, ANY error before launch MUST release
74
+ // the slot via failJob in the catch, or the server wedges all analyses.
75
+ let job;
76
+ try {
77
+ job = deps.createJob({ repoPath: finalDir });
78
+ }
79
+ catch (err) {
80
+ const msg = err instanceof Error ? err.message : '';
81
+ if (msg.includes('already in progress')) {
82
+ throw new BadRequestError(msg, 409);
83
+ }
84
+ throw err;
85
+ }
86
+ createdJobId = job.id;
87
+ // Promote staging → persistent upload dir. Both live under UPLOAD_ROOT's
88
+ // filesystem, so this rename stays atomic (no EXDEV).
89
+ await fsp.mkdir(UPLOAD_ROOT, { recursive: true });
90
+ await fsp.rename(innerRoot, finalDir);
91
+ promotedDir = finalDir;
92
+ const oldStage = stageRoot;
93
+ stageRoot = undefined;
94
+ await fsp.rm(oldStage, { recursive: true, force: true }).catch(() => { });
95
+ // Drop any crafted index the upload may have carried (a `.gitnexus`
96
+ // segment passes containment); the worker will build a fresh one.
97
+ await fsp
98
+ .rm(path.join(finalDir, '.gitnexus'), { recursive: true, force: true })
99
+ .catch(() => { });
100
+ deps.launch(job, finalDir, { registryName: finalName });
101
+ launched = true;
102
+ res.status(202).json({ jobId: job.id, status: job.status });
103
+ }
104
+ catch (err) {
105
+ // Release the single analysis slot if a job was created but never
106
+ // launched — otherwise the leaked queued job blocks all future analyses.
107
+ if (createdJobId && !launched) {
108
+ deps.failJob(createdJobId, err instanceof Error ? err.message : 'Upload failed');
109
+ }
110
+ if (stageRoot) {
111
+ await fsp.rm(stageRoot, { recursive: true, force: true }).catch(() => { });
112
+ }
113
+ if (promotedDir && !launched) {
114
+ await fsp.rm(promotedDir, { recursive: true, force: true }).catch(() => { });
115
+ }
116
+ if (err instanceof BadRequestError) {
117
+ res.status(err.status).json({ error: err.message });
118
+ return;
119
+ }
120
+ res.status(500).json({ error: 'Upload failed' });
121
+ }
122
+ };
123
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * JSON-safe projection of `AnalyzeResult` for the analyze-worker → parent IPC
3
+ * boundary (#2112 boundary audit; #2135).
4
+ *
5
+ * The forked analyze worker (`analyze-worker.ts`) reports completion to the
6
+ * parent over `child_process` IPC, which uses Node's DEFAULT `'json'`
7
+ * serialization — `api.ts` forks the worker with no `serialization:` option, so
8
+ * the channel runs `JSON.stringify`/`JSON.parse`, NOT V8 structured clone.
9
+ *
10
+ * `AnalyzeResult.pipelineResult` is populated on every successful analysis
11
+ * (`run-analyze.ts`) and carries `pipelineResult.graph` — the live
12
+ * `KnowledgeGraph` closure object. Sending the raw result across this channel is
13
+ * wrong three ways:
14
+ * 1. Waste — the graph's `nodes`/`relationships` getters force-materialize the
15
+ * ENTIRE graph into two arrays, then JSON-stringify them, on every analyze.
16
+ * On a large repo (the #2112 scenario) that is a multi-hundred-MB
17
+ * stringify+parse whose result is immediately discarded.
18
+ * 2. Silent corruption — the graph's methods are own function properties;
19
+ * `JSON.stringify` drops them with no error, so a `pipelineResult.graph`
20
+ * that survived the wire is a data-only husk whose `forEachNode(...)` throws
21
+ * "is not a function" far from the cause.
22
+ * 3. Conditional crash — a BigInt or circular reference anywhere in the
23
+ * payload makes `process.send` throw `TypeError` synchronously; the throw is
24
+ * caught in the worker and re-sent as `{type:'error'}`, turning a
25
+ * SUCCESSFUL analysis (DB already written) into a reported FAILURE. This is
26
+ * the #2112 failure family on the server path, and — unlike the parse-worker
27
+ * result boundary — it has no clone-safety net.
28
+ *
29
+ * The parent (`api.ts`) reads only `result.repoName`; `pipelineResult`'s real
30
+ * consumers (CLI skill generation) call `runFullAnalysis` in-process and never
31
+ * cross this fork. So the worker sends an explicit allowlist of the scalar
32
+ * fields, JSON-safe by construction.
33
+ */
34
+ import type { AnalyzeResult } from '../core/run-analyze.js';
35
+ /**
36
+ * The JSON-safe subset of `AnalyzeResult` that crosses the analyze-worker IPC
37
+ * boundary. A `Pick` allowlist — NOT `Omit<…, 'pipelineResult'>`. With `Pick`
38
+ * the allowlist IS the type, so the projection is exhaustive by construction:
39
+ * `projectAnalyzeResultForIpc`'s return literal must name exactly these keys
40
+ * (omitting one is a compile error), and a new field added to `AnalyzeResult`
41
+ * is simply absent from the wire until it is *deliberately* added here. `Omit`
42
+ * couldn't give that guarantee — it kept every other field, including OPTIONAL
43
+ * ones (e.g. `isPrimaryBranch?`), so an optional non-serializable field could be
44
+ * advertised by the type yet silently dropped by the runtime allowlist.
45
+ *
46
+ * `isPrimaryBranch` is intentionally excluded: the parent (`api.ts`) reads only
47
+ * `repoName`, and nothing consumes `isPrimaryBranch` across this fork (its CLI
48
+ * consumer calls `runFullAnalysis` in-process). Add a field here only when a
49
+ * server-side IPC consumer actually needs it — and only if it is JSON-safe.
50
+ */
51
+ export type AnalyzeResultIpc = Pick<AnalyzeResult, 'repoName' | 'repoPath' | 'stats' | 'alreadyUpToDate' | 'ftsRepairedOnly' | 'ftsSkipped'>;
52
+ /**
53
+ * Project an `AnalyzeResult` down to the JSON-safe fields the parent consumes,
54
+ * dropping `pipelineResult` (the live `KnowledgeGraph`) and any other field not
55
+ * in the `AnalyzeResultIpc` allowlist. The return literal is exhaustive over
56
+ * `AnalyzeResultIpc` (a missing key is a compile error).
57
+ */
58
+ export declare function projectAnalyzeResultForIpc(result: AnalyzeResult): AnalyzeResultIpc;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Project an `AnalyzeResult` down to the JSON-safe fields the parent consumes,
3
+ * dropping `pipelineResult` (the live `KnowledgeGraph`) and any other field not
4
+ * in the `AnalyzeResultIpc` allowlist. The return literal is exhaustive over
5
+ * `AnalyzeResultIpc` (a missing key is a compile error).
6
+ */
7
+ export function projectAnalyzeResultForIpc(result) {
8
+ return {
9
+ repoName: result.repoName,
10
+ repoPath: result.repoPath,
11
+ stats: result.stats,
12
+ alreadyUpToDate: result.alreadyUpToDate,
13
+ ftsRepairedOnly: result.ftsRepairedOnly,
14
+ ftsSkipped: result.ftsSkipped,
15
+ };
16
+ }
@@ -10,4 +10,20 @@
10
10
  * Child -> Parent: { type: 'complete', result: AnalyzeResult }
11
11
  * Child -> Parent: { type: 'error', message: string }
12
12
  */
13
- export {};
13
+ import { type AnalyzeResultIpc } from './analyze-worker-ipc.js';
14
+ export interface ProgressMessage {
15
+ type: 'progress';
16
+ phase: string;
17
+ percent: number;
18
+ message: string;
19
+ }
20
+ export interface CompleteMessage {
21
+ type: 'complete';
22
+ result: AnalyzeResultIpc;
23
+ }
24
+ export interface ErrorMessage {
25
+ type: 'error';
26
+ message: string;
27
+ }
28
+ /** Child → parent IPC messages. Shared with the parent-side launcher. */
29
+ export type WorkerMessage = ProgressMessage | CompleteMessage | ErrorMessage;
@@ -11,6 +11,7 @@
11
11
  * Child -> Parent: { type: 'error', message: string }
12
12
  */
13
13
  import { runFullAnalysis } from '../core/run-analyze.js';
14
+ import { projectAnalyzeResultForIpc } from './analyze-worker-ipc.js';
14
15
  import { closeLbug } from '../core/lbug/lbug-adapter.js';
15
16
  function send(msg) {
16
17
  process.send?.(msg);
@@ -48,7 +49,12 @@ process.on('message', async (msg) => {
48
49
  send({ type: 'progress', phase: 'log', percent: -1, message });
49
50
  },
50
51
  });
51
- send({ type: 'complete', result });
52
+ // Send a JSON-safe projection, NOT the raw result: the IPC channel is
53
+ // default-JSON serialization and `result.pipelineResult` carries the live
54
+ // KnowledgeGraph (wasteful to materialize, silently corrupted by JSON, and
55
+ // a BigInt/circular value would throw and mis-report this success as a
56
+ // failure). See analyze-worker-ipc.ts.
57
+ send({ type: 'complete', result: projectAnalyzeResultForIpc(result) });
52
58
  }
53
59
  catch (err) {
54
60
  send({ type: 'error', message: err?.message || 'Analysis failed' });