gitnexus 1.6.12-rc.2 → 1.6.12-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 (188) hide show
  1. package/dist/_shared/graph/types.d.ts +1 -1
  2. package/dist/_shared/graph/types.d.ts.map +1 -1
  3. package/dist/_shared/language-detection.d.ts.map +1 -1
  4. package/dist/_shared/language-detection.js +2 -0
  5. package/dist/_shared/language-detection.js.map +1 -1
  6. package/dist/_shared/languages.d.ts +1 -0
  7. package/dist/_shared/languages.d.ts.map +1 -1
  8. package/dist/_shared/languages.js +1 -0
  9. package/dist/_shared/languages.js.map +1 -1
  10. package/dist/_shared/lbug/schema-constants.d.ts +1 -1
  11. package/dist/_shared/lbug/schema-constants.d.ts.map +1 -1
  12. package/dist/_shared/lbug/schema-constants.js +2 -0
  13. package/dist/_shared/lbug/schema-constants.js.map +1 -1
  14. package/dist/_shared/scope-resolution/language-classification.d.ts +2 -1
  15. package/dist/_shared/scope-resolution/language-classification.d.ts.map +1 -1
  16. package/dist/_shared/scope-resolution/language-classification.js +3 -1
  17. package/dist/_shared/scope-resolution/language-classification.js.map +1 -1
  18. package/dist/_shared/scope-resolution/registries/class-registry.d.ts +3 -3
  19. package/dist/_shared/scope-resolution/registries/class-registry.js +3 -3
  20. package/dist/_shared/scope-resolution/registries/context.d.ts.map +1 -1
  21. package/dist/_shared/scope-resolution/registries/context.js +2 -0
  22. package/dist/_shared/scope-resolution/registries/context.js.map +1 -1
  23. package/dist/_shared/scope-resolution/resolve-type-ref.d.ts.map +1 -1
  24. package/dist/_shared/scope-resolution/resolve-type-ref.js +2 -0
  25. package/dist/_shared/scope-resolution/resolve-type-ref.js.map +1 -1
  26. package/dist/cli/analyze-config.d.ts +2 -2
  27. package/dist/cli/analyze-config.js +10 -33
  28. package/dist/cli/doctor.js +6 -4
  29. package/dist/cli/eval-server.js +13 -1
  30. package/dist/cli/i18n/en.d.ts +4 -2
  31. package/dist/cli/i18n/en.js +4 -2
  32. package/dist/cli/i18n/resources.d.ts +6 -2
  33. package/dist/cli/i18n/zh-CN.d.ts +2 -0
  34. package/dist/cli/i18n/zh-CN.js +4 -2
  35. package/dist/core/analysis-feature-registry.d.ts +1 -1
  36. package/dist/core/analysis-feature-registry.js +2 -0
  37. package/dist/core/auto-sync/starter.js +2 -2
  38. package/dist/core/embeddings/ast-utils.js +8 -3
  39. package/dist/core/embeddings/chunker.js +67 -10
  40. package/dist/core/embeddings/embedding-pipeline.d.ts +1 -1
  41. package/dist/core/embeddings/embedding-pipeline.js +1 -1
  42. package/dist/core/embeddings/structural-extractor.js +2 -2
  43. package/dist/core/embeddings/types.d.ts +4 -2
  44. package/dist/core/embeddings/types.js +18 -0
  45. package/dist/core/git-ref.d.ts +22 -0
  46. package/dist/core/git-ref.js +114 -0
  47. package/dist/core/git-staleness.js +14 -0
  48. package/dist/core/group/extractors/graphql-extractor.d.ts +1 -1
  49. package/dist/core/group/extractors/graphql-extractor.js +149 -13
  50. package/dist/core/group/extractors/manifest-extractor.d.ts +1 -1
  51. package/dist/core/group/extractors/manifest-extractor.js +2 -2
  52. package/dist/core/ingestion/call-extractors/zig-static-gating.d.ts +5 -8
  53. package/dist/core/ingestion/content-language-classification.d.ts +10 -0
  54. package/dist/core/ingestion/content-language-classification.js +30 -0
  55. package/dist/core/ingestion/filesystem-walker.d.ts +1 -0
  56. package/dist/core/ingestion/filesystem-walker.js +1 -1
  57. package/dist/core/ingestion/import-resolvers/node-workspace-packages.js +3 -2
  58. package/dist/core/ingestion/language-config.d.ts +82 -13
  59. package/dist/core/ingestion/language-config.js +217 -13
  60. package/dist/core/ingestion/language-provider.d.ts +88 -1
  61. package/dist/core/ingestion/languages/index.d.ts +7 -0
  62. package/dist/core/ingestion/languages/index.js +30 -1
  63. package/dist/core/ingestion/languages/objective-c/analysis-features.d.ts +10 -0
  64. package/dist/core/ingestion/languages/objective-c/analysis-features.js +19 -0
  65. package/dist/core/ingestion/languages/objective-c/compilation-unit-siblings.d.ts +13 -0
  66. package/dist/core/ingestion/languages/objective-c/compilation-unit-siblings.js +124 -0
  67. package/dist/core/ingestion/languages/objective-c/facts.d.ts +148 -0
  68. package/dist/core/ingestion/languages/objective-c/facts.js +1150 -0
  69. package/dist/core/ingestion/languages/objective-c/import-target.d.ts +11 -0
  70. package/dist/core/ingestion/languages/objective-c/import-target.js +122 -0
  71. package/dist/core/ingestion/languages/objective-c/macro-marker-preprocess.d.ts +24 -0
  72. package/dist/core/ingestion/languages/objective-c/macro-marker-preprocess.js +261 -0
  73. package/dist/core/ingestion/languages/objective-c/resolution-config.d.ts +41 -0
  74. package/dist/core/ingestion/languages/objective-c/resolution-config.js +282 -0
  75. package/dist/core/ingestion/languages/objective-c/scope-resolver.d.ts +2 -0
  76. package/dist/core/ingestion/languages/objective-c/scope-resolver.js +503 -0
  77. package/dist/core/ingestion/languages/objective-c.d.ts +2 -0
  78. package/dist/core/ingestion/languages/objective-c.js +300 -0
  79. package/dist/core/ingestion/languages/typescript/query.d.ts +31 -0
  80. package/dist/core/ingestion/languages/typescript/query.js +7 -1
  81. package/dist/core/ingestion/languages/typescript/tsconfig.js +3 -2
  82. package/dist/core/ingestion/languages/typescript.js +1 -1
  83. package/dist/core/ingestion/languages/zig/query.d.ts +26 -0
  84. package/dist/core/ingestion/languages/zig/query.js +61 -1
  85. package/dist/core/ingestion/languages/zig/range-binding.d.ts +8 -0
  86. package/dist/core/ingestion/languages/zig/range-binding.js +24 -0
  87. package/dist/core/ingestion/languages/zig/scope-resolver.d.ts +3 -2
  88. package/dist/core/ingestion/languages/zig/scope-resolver.js +18 -5
  89. package/dist/core/ingestion/languages/zig/this-alias-bindings.d.ts +58 -0
  90. package/dist/core/ingestion/languages/zig/this-alias-bindings.js +176 -0
  91. package/dist/core/ingestion/languages/zig/workspace-static-gating.d.ts +8 -0
  92. package/dist/core/ingestion/languages/zig/workspace-static-gating.js +82 -0
  93. package/dist/core/ingestion/model/registration-table.d.ts +3 -3
  94. package/dist/core/ingestion/model/registration-table.js +9 -5
  95. package/dist/core/ingestion/model/symbol-table.d.ts +1 -1
  96. package/dist/core/ingestion/model/symbol-table.js +2 -0
  97. package/dist/core/ingestion/model/type-registry.d.ts +1 -1
  98. package/dist/core/ingestion/parsing-processor.d.ts +22 -6
  99. package/dist/core/ingestion/parsing-processor.js +39 -20
  100. package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +9 -1
  101. package/dist/core/ingestion/pipeline-phases/parse-impl.js +307 -96
  102. package/dist/core/ingestion/pipeline-phases/parse-round-budget.d.ts +42 -0
  103. package/dist/core/ingestion/pipeline-phases/parse-round-budget.js +50 -0
  104. package/dist/core/ingestion/pipeline-phases/parse.d.ts +7 -1
  105. package/dist/core/ingestion/scope-extractor.js +4 -0
  106. package/dist/core/ingestion/scope-resolution/contract/scope-resolver.d.ts +13 -0
  107. package/dist/core/ingestion/scope-resolution/graph-bridge/ids.js +2 -0
  108. package/dist/core/ingestion/scope-resolution/graph-bridge/node-lookup.js +2 -0
  109. package/dist/core/ingestion/scope-resolution/passes/property-dispatch.d.ts +78 -5
  110. package/dist/core/ingestion/scope-resolution/passes/property-dispatch.js +262 -7
  111. package/dist/core/ingestion/scope-resolution/pipeline/phase.js +5 -2
  112. package/dist/core/ingestion/scope-resolution/pipeline/registry.js +2 -0
  113. package/dist/core/ingestion/scope-resolution/pipeline/run.js +10 -1
  114. package/dist/core/ingestion/scope-resolution/scope/walkers.d.ts +39 -3
  115. package/dist/core/ingestion/scope-resolution/scope/walkers.js +82 -3
  116. package/dist/core/ingestion/scope-resolution/value-ref-edges.d.ts +14 -0
  117. package/dist/core/ingestion/scope-resolution/value-ref-edges.js +14 -0
  118. package/dist/core/ingestion/tree-sitter-queries.js +2 -0
  119. package/dist/core/ingestion/utils/symbol-labels.js +2 -0
  120. package/dist/core/ingestion/workers/parse-worker.d.ts +1 -1
  121. package/dist/core/ingestion/workers/parse-worker.js +63 -3
  122. package/dist/core/ingestion/workers/worker-pool.d.ts +56 -2
  123. package/dist/core/ingestion/workers/worker-pool.js +117 -26
  124. package/dist/core/lbug/csv-generator.js +2 -0
  125. package/dist/core/lbug/schema.d.ts +3 -1
  126. package/dist/core/lbug/schema.js +27 -0
  127. package/dist/core/search/fts-indexes.js +13 -4
  128. package/dist/core/search/fts-schema.js +2 -0
  129. package/dist/core/tree-sitter/parser-loader.js +10 -0
  130. package/dist/core/tree-sitter/vendored-grammars.d.ts +1 -1
  131. package/dist/core/tree-sitter/vendored-grammars.js +2 -1
  132. package/dist/mcp/local/local-backend.d.ts +33 -0
  133. package/dist/mcp/local/local-backend.js +166 -5
  134. package/dist/mcp/tools.js +6 -4
  135. package/dist/server/analyze-job.d.ts +19 -1
  136. package/dist/server/analyze-job.js +14 -3
  137. package/dist/server/analyze-launch.d.ts +14 -0
  138. package/dist/server/analyze-launch.js +59 -14
  139. package/dist/server/analyze-worker-ipc.d.ts +6 -5
  140. package/dist/server/analyze-worker-ipc.js +4 -0
  141. package/dist/server/api.js +68 -21
  142. package/dist/server/git-clone.d.ts +38 -5
  143. package/dist/server/git-clone.js +216 -38
  144. package/dist/server/repo-projection.d.ts +88 -0
  145. package/dist/server/repo-projection.js +53 -0
  146. package/dist/storage/file-lock.js +5 -3
  147. package/dist/storage/parse-cache.js +32 -1
  148. package/dist/storage/parsedfile-store.js +53 -1
  149. package/dist/storage/v8-sidecar.d.ts +11 -0
  150. package/dist/storage/v8-sidecar.js +15 -7
  151. package/dist/utils/process-identity.d.ts +13 -0
  152. package/dist/utils/process-identity.js +18 -0
  153. package/package.json +1 -1
  154. package/scripts/build-tree-sitter-grammars.cjs +4 -3
  155. package/vendor/tree-sitter-objc/LICENSE +21 -0
  156. package/vendor/tree-sitter-objc/README.md +18 -0
  157. package/vendor/tree-sitter-objc/binding.gyp +35 -0
  158. package/vendor/tree-sitter-objc/bindings/node/binding.cc +19 -0
  159. package/vendor/tree-sitter-objc/bindings/node/binding_test.js +9 -0
  160. package/vendor/tree-sitter-objc/bindings/node/index.d.ts +27 -0
  161. package/vendor/tree-sitter-objc/bindings/node/index.js +11 -0
  162. package/vendor/tree-sitter-objc/grammar.js +1286 -0
  163. package/vendor/tree-sitter-objc/package.json +56 -0
  164. package/vendor/tree-sitter-objc/prebuilds/SHA256SUMS +6 -0
  165. package/vendor/tree-sitter-objc/prebuilds/darwin-arm64/tree-sitter-objc.node +0 -0
  166. package/vendor/tree-sitter-objc/prebuilds/darwin-x64/tree-sitter-objc.node +0 -0
  167. package/vendor/tree-sitter-objc/prebuilds/linux-arm64/tree-sitter-objc.node +0 -0
  168. package/vendor/tree-sitter-objc/prebuilds/linux-x64/tree-sitter-objc.node +0 -0
  169. package/vendor/tree-sitter-objc/prebuilds/win32-arm64/tree-sitter-objc.node +0 -0
  170. package/vendor/tree-sitter-objc/prebuilds/win32-x64/tree-sitter-objc.node +0 -0
  171. package/vendor/tree-sitter-objc/queries/folds.scm +20 -0
  172. package/vendor/tree-sitter-objc/queries/highlights.scm +216 -0
  173. package/vendor/tree-sitter-objc/queries/indents.scm +1 -0
  174. package/vendor/tree-sitter-objc/queries/injections.scm +10 -0
  175. package/vendor/tree-sitter-objc/queries/locals.scm +1 -0
  176. package/vendor/tree-sitter-objc/src/grammar.json +16292 -0
  177. package/vendor/tree-sitter-objc/src/node-types.json +7524 -0
  178. package/vendor/tree-sitter-objc/src/parser.c +684023 -0
  179. package/vendor/tree-sitter-objc/src/tree_sitter/alloc.h +54 -0
  180. package/vendor/tree-sitter-objc/src/tree_sitter/array.h +291 -0
  181. package/vendor/tree-sitter-objc/src/tree_sitter/parser.h +266 -0
  182. package/vendor/tree-sitter-objc/tree-sitter-objc.wasm +0 -0
  183. package/vendor/tree-sitter-objc/tree-sitter.json +38 -0
  184. package/web/assets/{agent-BMRqXorA.js → agent-C2n33ANd.js} +1 -1
  185. package/web/assets/{index-Dj3vQOHK.js → index-CHJoW_P1.js} +18 -18
  186. package/web/assets/src-jc8Ffy3T.js +1 -0
  187. package/web/index.html +2 -2
  188. package/web/assets/src-Df5C1Nz4.js +0 -1
@@ -15,6 +15,7 @@ import { fork } from 'child_process';
15
15
  import { fileURLToPath, pathToFileURL } from 'url';
16
16
  import { createRequire } from 'node:module';
17
17
  import { canonicalizePath, getStoragePath, INDEX_METADATA_FILE, listRegisteredRepos, registryPathEquals, } from '../storage/repo-manager.js';
18
+ import { BRANCHES_DIR, branchSlug } from '../storage/branch-index.js';
18
19
  import { logger } from '../core/logger.js';
19
20
  import { autoHeapCapMb } from '../core/ingestion/utils/effective-ram.js';
20
21
  import { isTerminalJobStatus } from './analyze-job.js';
@@ -32,17 +33,6 @@ const MAX_WORKER_RETRIES = 2;
32
33
  */
33
34
  const FINALIZE_SETTLE_TIMEOUT_MS = 60_000;
34
35
  const FINALIZE_SETTLE_POLL_MS = 200;
35
- /**
36
- * Resolve once the analyzed repo's index is settled at `storagePath`: the
37
- * LadybugDB file and metadata both exist AND were (re)written by THIS job
38
- * (mtime >= jobStartMs — bare existence is not enough, a re-analysis leaves
39
- * the previous index in place while it works), and no transient WAL/shadow/
40
- * checkpoint sidecars remain (the worker's native close has finished).
41
- *
42
- * Never rejects. Timing out logs and proceeds (pre-gate behavior) rather
43
- * than failing a job whose analysis genuinely succeeded — e.g. a no-op
44
- * non-force analyze legitimately rewrites nothing.
45
- */
46
36
  /**
47
37
  * Look up the analyzed repo's registered storage path. The request's
48
38
  * user-provided path is used only as a comparison key; the filesystem probes
@@ -56,7 +46,36 @@ const registeredStoragePath = async (targetPath) => {
56
46
  const entry = entries.find((e) => registryPathEquals(canonicalizePath(e.path), target));
57
47
  return entry?.storagePath ?? null;
58
48
  };
59
- const waitForSettledIndex = async (targetPath, jobStartMs) => {
49
+ /**
50
+ * Resolve the directory this run's index actually landed in.
51
+ *
52
+ * `registerRepo` always records the FLAT `.gitnexus` as `entry.storagePath`,
53
+ * but a pinned `--branch` run whose label differs from the flat slot's owner
54
+ * writes `lbug`/`gitnexus.json` under `branches/<slug>/` instead. Probing the
55
+ * flat path for such a run watches files it never rewrote, so the gate below
56
+ * would spin to its timeout on a perfectly successful analysis (#3199 review).
57
+ *
58
+ * `isPrimaryBranch` is the worker's own report of `!placement.branch`, so this
59
+ * follows the placement core actually chose rather than recomputing it here
60
+ * (the flat slot's recorded owner can be adopted mid-run, which would make a
61
+ * recomputation race the thing it is trying to observe).
62
+ */
63
+ const settleDirFor = (registryStoragePath, branch, isPrimaryBranch) => branch && isPrimaryBranch === false
64
+ ? path.join(registryStoragePath, BRANCHES_DIR, branchSlug(branch))
65
+ : registryStoragePath;
66
+ /**
67
+ * Resolve once the analyzed repo's index is settled at `storagePath`: the
68
+ * LadybugDB file and metadata both exist AND were (re)written by THIS job
69
+ * (mtime >= jobStartMs — bare existence is not enough, a re-analysis leaves
70
+ * the previous index in place while it works), and no transient WAL/shadow/
71
+ * checkpoint sidecars remain (the worker's native close has finished).
72
+ *
73
+ * Never rejects. Timing out logs and proceeds (pre-gate behavior) rather
74
+ * than failing a job whose analysis genuinely succeeded. The `alreadyUpToDate`
75
+ * fast path never rewrites `lbug` (see `run-analyze.ts`) and skips this wait
76
+ * at the `complete` handler so it does not hold the analyze slot for 60s.
77
+ */
78
+ const waitForSettledIndex = async (targetPath, jobStartMs, branch, isPrimaryBranch) => {
60
79
  const settled = (storagePath) => {
61
80
  try {
62
81
  const lbugStat = statSync(path.join(storagePath, 'lbug'));
@@ -74,7 +93,7 @@ const waitForSettledIndex = async (targetPath, jobStartMs) => {
74
93
  // Re-resolved each round: the worker registers the repo as part of the
75
94
  // finalization this gate is waiting out.
76
95
  const storagePath = await registeredStoragePath(targetPath);
77
- if (storagePath && settled(storagePath))
96
+ if (storagePath && settled(settleDirFor(storagePath, branch, isPrimaryBranch)))
78
97
  return;
79
98
  if (Date.now() > deadline) {
80
99
  logger.warn({ targetPath }, 'analyze finalization not visible after timeout; completing job anyway');
@@ -121,6 +140,13 @@ export function createLaunchAnalysisWorker(deps) {
121
140
  });
122
141
  // Capture stderr for crash diagnostics
123
142
  let stderrChunks = '';
143
+ // A terminal IPC message (`complete`/`error`) means the worker finished
144
+ // and is now winding down — it calls process.exit(0) ~500ms later. The
145
+ // job is deliberately still non-terminal at that point because the
146
+ // finalization gate is running, so without this flag the exit handler
147
+ // below reads that clean exit as a crash and retries a SUCCESSFUL
148
+ // analysis, three times, before failing it (#3199 review).
149
+ let terminalIpcSeen = false;
124
150
  child.stderr?.on('data', (chunk) => {
125
151
  stderrChunks += chunk.toString();
126
152
  if (stderrChunks.length > 4096)
@@ -134,6 +160,8 @@ export function createLaunchAnalysisWorker(deps) {
134
160
  const current = jobManager.getJob(job.id);
135
161
  if (!current || isTerminalJobStatus(current.status))
136
162
  return;
163
+ if (msg.type === 'complete' || msg.type === 'error')
164
+ terminalIpcSeen = true;
137
165
  if (msg.type === 'progress') {
138
166
  jobManager.updateJob(job.id, {
139
167
  status: 'analyzing',
@@ -151,7 +179,16 @@ export function createLaunchAnalysisWorker(deps) {
151
179
  // below true in practice: the repo is actually queryable when the
152
180
  // client receives the SSE complete event, and an index this run knows
153
181
  // to be incomplete is never published at all.
154
- waitForSettledIndex(targetPath, jobStartMs)
182
+ //
183
+ // alreadyUpToDate never opens LadybugDB and never rewrites `lbug`
184
+ // (run-analyze.ts early-return; CLI notes the same). The mtime gate
185
+ // would spin the full 60s and hold the single global analyze slot.
186
+ // ftsRepairedOnly DOES rewrite `lbug` (initLbug + createSearchFTSIndexes)
187
+ // so it still waits.
188
+ const settle = msg.result.alreadyUpToDate
189
+ ? Promise.resolve()
190
+ : waitForSettledIndex(targetPath, jobStartMs, opts.branch, msg.result.isPrimaryBranch);
191
+ settle
155
192
  .then(() => closeDbHandle())
156
193
  .catch(() => { }) // best-effort: eviction failure must not fail the job
157
194
  .then(() => {
@@ -243,6 +280,13 @@ export function createLaunchAnalysisWorker(deps) {
243
280
  const j = jobManager.getJob(job.id);
244
281
  if (!j || isTerminalJobStatus(j.status))
245
282
  return;
283
+ // The worker already reported a terminal outcome; this exit is it
284
+ // winding down, not dying. The job is still non-terminal only because
285
+ // the finalization gate above has not resolved yet, and that gate owns
286
+ // the outcome — retrying here would fork a second worker over a
287
+ // finished, successful analysis.
288
+ if (terminalIpcSeen)
289
+ return;
246
290
  // Worker crashed — attempt retry if under the limit
247
291
  if (j.retryCount < MAX_WORKER_RETRIES) {
248
292
  j.retryCount++;
@@ -283,6 +327,7 @@ export function createLaunchAnalysisWorker(deps) {
283
327
  ...(opts.springActuatorPath ? { springActuatorPath: opts.springActuatorPath } : {}),
284
328
  ...(opts.asyncApiSpecPath ? { asyncApiSpecPath: opts.asyncApiSpecPath } : {}),
285
329
  ...(opts.registryName ? { registryName: opts.registryName } : {}),
330
+ ...(opts.branch ? { branch: opts.branch } : {}),
286
331
  },
287
332
  });
288
333
  };
@@ -43,12 +43,13 @@ import type { AnalyzeResult } from '../core/run-analyze.js';
43
43
  * ones (e.g. `isPrimaryBranch?`), so an optional non-serializable field could be
44
44
  * advertised by the type yet silently dropped by the runtime allowlist.
45
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.
46
+ * `isPrimaryBranch` IS on the wire, under exactly the rule this comment used to
47
+ * cite for excluding it: a server-side consumer now needs it. `analyze-launch.ts`
48
+ * settles the index the run actually wrote, and only the worker knows whether
49
+ * core chose the flat slot or a `branches/<slug>/` sub-slot. It is a boolean, so
50
+ * it is JSON-safe by construction.
50
51
  */
51
- export type AnalyzeResultIpc = Pick<AnalyzeResult, 'repoName' | 'repoPath' | 'stats' | 'alreadyUpToDate' | 'ftsRepairedOnly' | 'ftsSkipped' | 'graphWriteCollapsed'>;
52
+ export type AnalyzeResultIpc = Pick<AnalyzeResult, 'repoName' | 'repoPath' | 'stats' | 'alreadyUpToDate' | 'ftsRepairedOnly' | 'ftsSkipped' | 'graphWriteCollapsed' | 'isPrimaryBranch'>;
52
53
  /**
53
54
  * Project an `AnalyzeResult` down to the JSON-safe fields the parent consumes,
54
55
  * dropping `pipelineResult` (the live `KnowledgeGraph`) and any other field not
@@ -16,5 +16,9 @@ export function projectAnalyzeResultForIpc(result) {
16
16
  // outcome the CLI does; without it the worker reports a clean `complete`
17
17
  // for a run whose edges are mostly missing.
18
18
  graphWriteCollapsed: result.graphWriteCollapsed,
19
+ // Tells the parent which slot this run wrote — the flat `.gitnexus` or a
20
+ // `branches/<slug>/` sub-slot — so its finalization gate watches the files
21
+ // this job actually rewrote (#3199 review).
22
+ isPrimaryBranch: result.isPrimaryBranch,
19
23
  };
20
24
  }
@@ -29,8 +29,14 @@ import { measurePersistedEmbeddingCount, persistedEmbeddingCountOrUndefined, } f
29
29
  import { assertString, BadRequestError, createRouteLimiter } from './validation.js';
30
30
  import { parseGrepQuery, GREP_TIME_BUDGET_MS } from './grep-params.js';
31
31
  import { runGrepScanInWorker } from './grep-scan.js';
32
- import { extractWebRepoName, getCloneDir, cloneOrPull, warnIfInsecureAzureConfig, GITHUB_TOKEN_HOSTS, } from './git-clone.js';
32
+ import { analyzeCloneOptions, extractWebRepoName, getCloneDir, cloneOrPull, warnIfInsecureAzureConfig, GITHUB_TOKEN_HOSTS, } from './git-clone.js';
33
33
  import { createAnalyzeUploadHandler } from './analyze-upload.js';
34
+ import { checkStalenessAsync } from '../core/git-staleness.js';
35
+ import { projectRepoDetail, projectRepoListEntry, resolveLastCommit } from './repo-projection.js';
36
+ // Shared with the CLI's `--branch` (via the analyze-config wrapper) so both
37
+ // entry points accept the same refs. Imported from core — not cli/ — so
38
+ // createServer does not close a cycle with cli/serve.ts.
39
+ import { InvalidBranchError, validateBranchName } from '../core/git-ref.js';
34
40
  import { assertServeAuthForPublicOrigin, createPublicOriginMatcher, createWriteOriginGuard, logOriginPolicy, PUBLIC_ORIGIN_ENV, resolveTrustProxy, TRUST_PROXY_ENV, warnIfRateLimitKeysCollapse, } from './middleware.js';
35
41
  import { createLaunchAnalysisWorker } from './analyze-launch.js';
36
42
  import { UPLOAD_ROOT } from './upload-paths.js';
@@ -805,17 +811,19 @@ export const createServer = async (port, host = '127.0.0.1') => {
805
811
  res.json(buildServerInfo(updateController.snapshot()));
806
812
  });
807
813
  // List all registered repos
808
- app.get('/api/repos', async (_req, res) => {
814
+ // Rate-limited (CodeQL js/missing-rate-limiting) because this route now spawns
815
+ // one `git rev-list` per registered repo to answer freshness: an unauthenticated
816
+ // GET that costs N subprocesses is worth the same 60 rpm/IP ceiling `/api/repo`
817
+ // already carries. Web callers hit this on connect/switch, never in a loop.
818
+ app.get('/api/repos', createRouteLimiter(), async (_req, res) => {
809
819
  try {
810
820
  const repos = await listRegisteredRepos();
811
- res.json(repos.map((r) => ({
812
- name: r.name,
813
- path: r.path,
814
- repoPath: r.path,
815
- indexedAt: r.indexedAt,
816
- lastCommit: r.lastCommit,
817
- stats: r.stats,
818
- })));
821
+ // Checked in parallel, for the reason `list_repos` already does it that
822
+ // way: each check spawns an async `git rev-list`, and the sequential
823
+ // variant took ~50s across 200 repos (#1363). Projecting inside the map
824
+ // keeps the entry and its own check together — an index-matched second
825
+ // array is the shape that silently mispairs them if either is reordered.
826
+ res.json(await Promise.all(repos.map(async (r) => projectRepoListEntry(r, await checkStalenessAsync(r.path, r.lastCommit)))));
819
827
  }
820
828
  catch (err) {
821
829
  res.status(500).json({ error: err.message || 'Failed to list repos' });
@@ -841,12 +849,8 @@ export const createServer = async (port, host = '127.0.0.1') => {
841
849
  return;
842
850
  }
843
851
  const meta = await loadMeta(entry.storagePath);
844
- res.json({
845
- name: entry.name,
846
- repoPath: entry.path,
847
- indexedAt: meta?.indexedAt ?? entry.indexedAt,
848
- stats: meta?.stats ?? entry.stats ?? {},
849
- });
852
+ const staleness = await checkStalenessAsync(entry.path, resolveLastCommit(entry, meta));
853
+ res.json(projectRepoDetail(entry, meta, staleness));
850
854
  }
851
855
  catch (err) {
852
856
  res.status(500).json({ error: err.message || 'Failed to get repo info' });
@@ -1264,7 +1268,7 @@ export const createServer = async (port, host = '127.0.0.1') => {
1264
1268
  // POST /api/analyze — start a new analysis job
1265
1269
  app.post('/api/analyze', createRouteLimiter({ limit: 10 }), requireTrustedOrigin, async (req, res) => {
1266
1270
  try {
1267
- const { url: repoUrl, path: repoLocalPath, force, embeddings, dropEmbeddings, springActuatorPath, asyncApiSpecPath, token: repoToken, } = req.body;
1271
+ const { url: repoUrl, path: repoLocalPath, force, embeddings, dropEmbeddings, springActuatorPath, asyncApiSpecPath, token: repoToken, branch: repoBranch, } = req.body;
1268
1272
  // Input type validation
1269
1273
  if (repoUrl !== undefined && typeof repoUrl !== 'string') {
1270
1274
  res.status(400).json({ error: '"url" must be a string' });
@@ -1288,6 +1292,27 @@ export const createServer = async (port, host = '127.0.0.1') => {
1288
1292
  res.status(400).json({ error: 'Provide "url" (git URL) or "path" (local path)' });
1289
1293
  return;
1290
1294
  }
1295
+ // Branch: optional index-branch selector, validated with the same rules
1296
+ // as the CLI's `--branch` so both entry points accept the same refs.
1297
+ // Rejecting here (rather than letting the clone fail) keeps a malformed
1298
+ // ref from ever reaching `git`.
1299
+ if (repoBranch !== undefined && typeof repoBranch !== 'string') {
1300
+ res.status(400).json({ error: '"branch" must be a string' });
1301
+ return;
1302
+ }
1303
+ let analyzeBranch;
1304
+ if (repoBranch !== undefined) {
1305
+ try {
1306
+ analyzeBranch = validateBranchName(repoBranch, '"branch"');
1307
+ }
1308
+ catch (err) {
1309
+ if (err instanceof InvalidBranchError) {
1310
+ res.status(400).json({ error: err.message });
1311
+ return;
1312
+ }
1313
+ throw err;
1314
+ }
1315
+ }
1291
1316
  // Token: optional, restricted charset to prevent header smuggling
1292
1317
  // (CRLF), bound length, and bound to github.com (see validateAnalyzeToken).
1293
1318
  const tokenError = validateAnalyzeToken(repoToken, repoUrl);
@@ -1311,7 +1336,11 @@ export const createServer = async (port, host = '127.0.0.1') => {
1311
1336
  res.status(400).json({ error: '"path" must be an absolute path' });
1312
1337
  return;
1313
1338
  }
1314
- const job = jobManager.createJob({ repoUrl, repoPath: repoLocalPath });
1339
+ const job = jobManager.createJob({
1340
+ repoUrl,
1341
+ repoPath: repoLocalPath,
1342
+ branch: analyzeBranch,
1343
+ });
1315
1344
  // If job was already running (dedup), just return its id. The token is
1316
1345
  // not part of the dedup identity and is never stored on the job, so a
1317
1346
  // token on THIS request had no effect — the existing job already
@@ -1337,17 +1366,21 @@ export const createServer = async (port, host = '127.0.0.1') => {
1337
1366
  // Clone if URL provided
1338
1367
  if (repoUrl && !repoLocalPath) {
1339
1368
  const repoName = extractWebRepoName(repoUrl);
1340
- targetPath = getCloneDir(repoName);
1369
+ // Branch-pinned runs get their own clone dir, so they never share
1370
+ // a working tree with the unpinned one (see getCloneDir).
1371
+ targetPath = getCloneDir(repoName, analyzeBranch);
1341
1372
  jobManager.updateJob(job.id, {
1342
1373
  status: 'cloning',
1343
- repoName,
1374
+ // url+branch: same value as registryName (dir basename), not
1375
+ // the extractWebRepoName stem used only as getCloneDir's first arg.
1376
+ repoName: analyzeBranch ? path.basename(targetPath) : repoName,
1344
1377
  progress: { phase: 'cloning', percent: 0, message: `Cloning ${repoUrl}...` },
1345
1378
  });
1346
1379
  await cloneOrPull(repoUrl, targetPath, (progress) => {
1347
1380
  jobManager.updateJob(job.id, {
1348
1381
  progress: { phase: progress.phase, percent: 5, message: progress.message },
1349
1382
  });
1350
- }, repoToken ? { token: repoToken } : undefined);
1383
+ }, analyzeCloneOptions(repoToken, analyzeBranch));
1351
1384
  }
1352
1385
  if (!targetPath) {
1353
1386
  throw new Error('No target path resolved');
@@ -1358,6 +1391,20 @@ export const createServer = async (port, host = '127.0.0.1') => {
1358
1391
  dropEmbeddings,
1359
1392
  springActuatorPath,
1360
1393
  asyncApiSpecPath,
1394
+ branch: analyzeBranch,
1395
+ // Both clone dirs share an `origin`, so the name `registerRepo`
1396
+ // infers from the remote would be identical and the second one
1397
+ // would fail with RegistryNameCollisionError. Register the pinned
1398
+ // clone under its directory name instead: unique per branch, and
1399
+ // it re-derives through getCloneDir for DELETE /api/repo.
1400
+ //
1401
+ // Gated on the SAME condition as the clone above: when a caller
1402
+ // supplies both `url` and `path` nothing is cloned, and renaming
1403
+ // the operator's own local repo after its directory would be a
1404
+ // surprise unrelated to branch pinning.
1405
+ ...(analyzeBranch && repoUrl && !repoLocalPath
1406
+ ? { registryName: path.basename(targetPath) }
1407
+ : {}),
1361
1408
  });
1362
1409
  }
1363
1410
  catch (err) {
@@ -25,12 +25,25 @@ export declare function extractRepoName(url: string): string;
25
25
  * `extractRepoName()` strict for internal/security-sensitive callers.
26
26
  */
27
27
  export declare function extractWebRepoName(url: string): string;
28
- /** Get the clone target directory for a repo name. */
29
- export declare function getCloneDir(repoName: string): string;
28
+ /** Get the clone target directory for a repo name, optionally pinned to a branch. */
29
+ export declare function getCloneDir(repoName: string, branch?: string): string;
30
30
  export interface CloneProgress {
31
31
  phase: 'cloning' | 'pulling';
32
32
  message: string;
33
33
  }
34
+ /**
35
+ * Build the `cloneOrPull` options for an `/api/analyze` request.
36
+ *
37
+ * Extracted from the route so the token/branch combination is unit-testable.
38
+ * Inline, the branch-only case was the one nothing asserted: every existing
39
+ * test still passed if `branch` were dropped whenever no token was supplied —
40
+ * i.e. silently cloning the default branch for every public URL, which is the
41
+ * exact behavior #3198 is about (#3199 review).
42
+ *
43
+ * Returns `undefined` rather than `{}` when neither is set, because that is
44
+ * what `cloneOrPull` treats as "no options" at its own call sites.
45
+ */
46
+ export declare function analyzeCloneOptions(token?: string, branch?: string): Pick<CloneOrPullOptions, 'token' | 'branch'> | undefined;
34
47
  export interface CloneOrPullOptions {
35
48
  token?: string;
36
49
  allowedCloneRoot?: string;
@@ -119,9 +132,29 @@ export declare function getRemoteOriginUrl(cwd: string, timeoutMs?: number): Pro
119
132
  export declare function assertRemoteMatchesRequestedUrl(targetDir: string, requestedUrl: string, timeoutMs?: number): Promise<void>;
120
133
  /**
121
134
  * Clone or pull a git repository.
122
- * If targetDir doesn't exist: git clone --depth 1
123
- * If targetDir exists with .git: git pull --ff-only (after verifying the
124
- * existing clone's remote.origin matches the requested URL).
135
+ *
136
+ * If targetDir doesn't exist: git clone --depth 1, adding `--branch <branch>`
137
+ * when one is requested.
138
+ *
139
+ * If targetDir exists with .git, its remote.origin is verified against the
140
+ * requested URL first, and then the branch decides the update:
141
+ * - no `options.branch`: git pull --ff-only, which updates the current
142
+ * branch in place via its configured upstream. Nothing moves, so no
143
+ * dirty-tree check applies.
144
+ * - a `options.branch` that is ALREADY the current named branch: restore
145
+ * GitNexus overlays, then fetch via
146
+ * `+refs/heads/<branch>:refs/remotes/origin/<branch>` and
147
+ * `checkout -B <branch> origin/<branch>` (shallow clones cannot
148
+ * `merge --ff-only` across a 2+ commit move). Never a raw
149
+ * `origin <branch>` pull refspec. No porcelain refuse.
150
+ * - a detached HEAD whose SHA already matches the requested ref (tag /
151
+ * SHA pin): restore overlays only. Do not fetch/merge — a same-named
152
+ * branch could otherwise fast-forward the pin past the tag.
153
+ * - a `options.branch` that DIFFERS from the current pin: fetch that ref,
154
+ * then `checkout -B <branch> origin/<branch>` — so the requested branch,
155
+ * not the one already checked out, is what ends up in the working tree.
156
+ * This is the switching case, and it refuses a dirty tree unless
157
+ * `overwriteLocalChanges` is set.
125
158
  *
126
159
  * Security:
127
160
  * - targetDir must resolve inside CLONE_ROOT (~/.gitnexus/repos/). The