gitnexus 1.6.11-rc.2 → 1.6.11-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 (158) hide show
  1. package/README.md +43 -1
  2. package/dist/_shared/impact-risk.d.ts +37 -0
  3. package/dist/_shared/impact-risk.d.ts.map +1 -0
  4. package/dist/_shared/impact-risk.js +92 -0
  5. package/dist/_shared/impact-risk.js.map +1 -0
  6. package/dist/_shared/index.d.ts +2 -0
  7. package/dist/_shared/index.d.ts.map +1 -1
  8. package/dist/_shared/index.js +2 -0
  9. package/dist/_shared/index.js.map +1 -1
  10. package/dist/cli/ai-context.js +4 -4
  11. package/dist/cli/analyze-config.d.ts +2 -0
  12. package/dist/cli/analyze-config.js +16 -0
  13. package/dist/cli/analyze-options.d.ts +4 -0
  14. package/dist/cli/analyze.d.ts +17 -0
  15. package/dist/cli/analyze.js +43 -7
  16. package/dist/cli/eval-server.js +22 -5
  17. package/dist/cli/group.js +7 -1
  18. package/dist/cli/help-i18n.js +2 -0
  19. package/dist/cli/i18n/en.d.ts +12 -2
  20. package/dist/cli/i18n/en.js +12 -2
  21. package/dist/cli/i18n/resources.d.ts +22 -2
  22. package/dist/cli/i18n/zh-CN.d.ts +10 -0
  23. package/dist/cli/i18n/zh-CN.js +12 -2
  24. package/dist/cli/index.js +11 -4
  25. package/dist/cli/status.js +91 -6
  26. package/dist/cli/watch-queue.d.ts +41 -0
  27. package/dist/cli/watch-queue.js +184 -0
  28. package/dist/cli/watch.d.ts +20 -0
  29. package/dist/cli/watch.js +372 -0
  30. package/dist/cli/wiki.js +15 -2
  31. package/dist/config/ignore-service.d.ts +11 -0
  32. package/dist/config/ignore-service.js +41 -4
  33. package/dist/config/repo-control-file.d.ts +3 -0
  34. package/dist/config/repo-control-file.js +115 -0
  35. package/dist/core/group/config-parser.js +20 -2
  36. package/dist/core/group/cross-impact.d.ts +2 -1
  37. package/dist/core/group/cross-impact.js +33 -2
  38. package/dist/core/group/extractors/fs-utils.d.ts +2 -0
  39. package/dist/core/group/extractors/fs-utils.js +86 -0
  40. package/dist/core/group/extractors/graphql-extractor.d.ts +17 -0
  41. package/dist/core/group/extractors/graphql-extractor.js +652 -0
  42. package/dist/core/group/extractors/java-workspace-extractor.js +244 -31
  43. package/dist/core/group/extractors/manifest-extractor.d.ts +1 -1
  44. package/dist/core/group/extractors/manifest-extractor.js +1 -1
  45. package/dist/core/group/matching.js +8 -1
  46. package/dist/core/group/service.js +5 -1
  47. package/dist/core/group/storage.js +1 -0
  48. package/dist/core/group/sync.d.ts +3 -1
  49. package/dist/core/group/sync.js +43 -5
  50. package/dist/core/group/types.d.ts +19 -5
  51. package/dist/core/incremental/derived-writeback.d.ts +36 -0
  52. package/dist/core/incremental/derived-writeback.js +68 -0
  53. package/dist/core/incremental/subgraph-extract.d.ts +6 -4
  54. package/dist/core/incremental/subgraph-extract.js +7 -5
  55. package/dist/core/index-content-drift.d.ts +54 -0
  56. package/dist/core/index-content-drift.js +127 -0
  57. package/dist/core/ingestion/filesystem-walker.d.ts +15 -5
  58. package/dist/core/ingestion/filesystem-walker.js +20 -3
  59. package/dist/core/ingestion/frameworks/spring/dynamic-lookups.d.ts +22 -0
  60. package/dist/core/ingestion/frameworks/spring/dynamic-lookups.js +124 -0
  61. package/dist/core/ingestion/language-provider.d.ts +43 -0
  62. package/dist/core/ingestion/languages/csharp/razor-view-components.d.ts +62 -0
  63. package/dist/core/ingestion/languages/csharp/razor-view-components.js +954 -0
  64. package/dist/core/ingestion/languages/csharp/resolution-config.d.ts +3 -0
  65. package/dist/core/ingestion/languages/csharp/resolution-config.js +6 -1
  66. package/dist/core/ingestion/languages/csharp/scope-resolver.js +8 -0
  67. package/dist/core/ingestion/languages/java/capture-side-channel.d.ts +4 -0
  68. package/dist/core/ingestion/languages/java/capture-side-channel.js +16 -0
  69. package/dist/core/ingestion/languages/java/captures.js +14 -1
  70. package/dist/core/ingestion/languages/java/lombok-synthesizer.d.ts +42 -0
  71. package/dist/core/ingestion/languages/java/lombok-synthesizer.js +439 -0
  72. package/dist/core/ingestion/languages/java/scope-resolver.js +2 -0
  73. package/dist/core/ingestion/languages/java/spring-dynamic-lookup.d.ts +8 -0
  74. package/dist/core/ingestion/languages/java/spring-dynamic-lookup.js +62 -0
  75. package/dist/core/ingestion/languages/java.js +2 -0
  76. package/dist/core/ingestion/languages/jvm/accessor-synthesis.d.ts +99 -0
  77. package/dist/core/ingestion/languages/jvm/accessor-synthesis.js +173 -0
  78. package/dist/core/ingestion/languages/jvm/beanspec.d.ts +17 -0
  79. package/dist/core/ingestion/languages/jvm/beanspec.js +42 -0
  80. package/dist/core/ingestion/languages/kotlin/capture-side-channel.d.ts +5 -0
  81. package/dist/core/ingestion/languages/kotlin/capture-side-channel.js +16 -0
  82. package/dist/core/ingestion/languages/kotlin/captures.js +14 -1
  83. package/dist/core/ingestion/languages/kotlin/lombok-synthesizer.d.ts +26 -0
  84. package/dist/core/ingestion/languages/kotlin/lombok-synthesizer.js +417 -0
  85. package/dist/core/ingestion/languages/kotlin/scope-resolver.js +2 -0
  86. package/dist/core/ingestion/languages/kotlin/spring-dynamic-lookup.d.ts +8 -0
  87. package/dist/core/ingestion/languages/kotlin/spring-dynamic-lookup.js +77 -0
  88. package/dist/core/ingestion/languages/kotlin.js +2 -0
  89. package/dist/core/ingestion/pipeline-phases/di.js +47 -14
  90. package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +3 -1
  91. package/dist/core/ingestion/pipeline-phases/parse-impl.js +57 -86
  92. package/dist/core/ingestion/pipeline-phases/parse.d.ts +2 -0
  93. package/dist/core/ingestion/pipeline-phases/runner.d.ts +4 -1
  94. package/dist/core/ingestion/pipeline-phases/runner.js +34 -14
  95. package/dist/core/ingestion/pipeline-phases/scan.js +25 -13
  96. package/dist/core/ingestion/pipeline.d.ts +6 -0
  97. package/dist/core/ingestion/pipeline.js +44 -12
  98. package/dist/core/ingestion/scope-extractor.js +1 -0
  99. package/dist/core/ingestion/utils/symbol-labels.d.ts +2 -2
  100. package/dist/core/ingestion/utils/symbol-labels.js +2 -2
  101. package/dist/core/ingestion/workers/parse-worker.js +28 -2
  102. package/dist/core/lbug/lbug-adapter.d.ts +59 -0
  103. package/dist/core/lbug/lbug-adapter.js +154 -1
  104. package/dist/core/run-analyze.d.ts +17 -0
  105. package/dist/core/run-analyze.js +231 -23
  106. package/dist/core/search/fts-indexes.d.ts +19 -1
  107. package/dist/core/search/fts-indexes.js +28 -1
  108. package/dist/core/wiki/generator.js +8 -0
  109. package/dist/core/wiki/grok-client.d.ts +21 -0
  110. package/dist/core/wiki/grok-client.js +287 -0
  111. package/dist/core/wiki/llm-client.d.ts +1 -1
  112. package/dist/core/wiki/llm-client.js +5 -2
  113. package/dist/core/wiki/local-cli-client.d.ts +11 -0
  114. package/dist/core/wiki/local-cli-client.js +22 -9
  115. package/dist/mcp/local/local-backend.d.ts +24 -7
  116. package/dist/mcp/local/local-backend.js +190 -81
  117. package/dist/mcp/local/pdg-impact.d.ts +8 -4
  118. package/dist/mcp/local/pdg-impact.js +7 -2
  119. package/dist/mcp/repository-policy.d.ts +5 -1
  120. package/dist/mcp/repository-policy.js +48 -4
  121. package/dist/mcp/resources.js +2 -1
  122. package/dist/mcp/server.js +6 -5
  123. package/dist/mcp/tools.js +31 -19
  124. package/dist/server/api.js +23 -64
  125. package/dist/server/grep-params.d.ts +18 -0
  126. package/dist/server/grep-params.js +83 -0
  127. package/dist/server/grep-scan.d.ts +23 -0
  128. package/dist/server/grep-scan.js +107 -0
  129. package/dist/server/grep-worker.d.ts +1 -0
  130. package/dist/server/grep-worker.js +12 -0
  131. package/dist/server/mcp-http.d.ts +8 -0
  132. package/dist/server/mcp-http.js +16 -1
  133. package/dist/storage/file-hash.d.ts +5 -0
  134. package/dist/storage/file-hash.js +16 -6
  135. package/dist/storage/fs-atomic.d.ts +24 -0
  136. package/dist/storage/fs-atomic.js +86 -2
  137. package/dist/storage/git.d.ts +19 -8
  138. package/dist/storage/git.js +83 -24
  139. package/dist/storage/gitnexus-managed-paths.d.ts +36 -0
  140. package/dist/storage/gitnexus-managed-paths.js +46 -0
  141. package/dist/storage/parse-cache.d.ts +22 -4
  142. package/dist/storage/parse-cache.js +106 -29
  143. package/dist/storage/parsedfile-store.d.ts +34 -60
  144. package/dist/storage/parsedfile-store.js +177 -171
  145. package/dist/storage/repo-manager.d.ts +11 -1
  146. package/dist/storage/repo-manager.js +23 -2
  147. package/dist/storage/repo-meta.d.ts +12 -0
  148. package/dist/storage/v8-sidecar.d.ts +48 -0
  149. package/dist/storage/v8-sidecar.js +347 -0
  150. package/dist/types/pipeline.d.ts +14 -0
  151. package/package.json +4 -1
  152. package/scripts/cross-platform-shard.ts +4 -2
  153. package/scripts/cross-platform-tests.ts +7 -1
  154. package/skills/gitnexus-cli.md +11 -3
  155. package/skills/gitnexus-impact-analysis.md +9 -0
  156. package/web/assets/{agent-Dr4l5EOp.js → agent-CFqT4hjR.js} +108 -104
  157. package/web/assets/{index-2zdvEdzg.js → index-BMIniRtX.js} +3 -3
  158. package/web/index.html +1 -1
@@ -0,0 +1,372 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import { watch } from 'chokidar';
4
+ import { createWatchIgnorePredicate } from '../config/ignore-service.js';
5
+ import { analyzeFailureMayHaveMutatedLiveIndex, runFullAnalysis, } from '../core/run-analyze.js';
6
+ import { getGitRoot, hasGitDir } from '../storage/git.js';
7
+ import { GITNEXUS_DIR } from '../storage/repo-meta.js';
8
+ import { loadAnalyzeConfigStrict, mergeAnalyzeOptions, validateBranchName, } from './analyze-config.js';
9
+ import { ensureHeap } from './analyze.js';
10
+ import { cliError, cliInfo, cliWarn } from './cli-message.js';
11
+ import { WATCH_FULL_REFRESH_PATH, WatchRefreshQueue, } from './watch-queue.js';
12
+ const DEFAULT_DEBOUNCE_MS = 300;
13
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
14
+ const MAX_FILE_SIZE_KB = 32 * 1024;
15
+ const TRANSIENT_WATCH_ERROR_CODES = new Set(['EACCES', 'ENOENT', 'ENOTDIR', 'EPERM']);
16
+ function posixWatchPath(filePath) {
17
+ return filePath.replace(/\\/g, '/').replace(/^\.\/+/, '');
18
+ }
19
+ export function isRelevantWatchPath(filePath) {
20
+ const normalized = posixWatchPath(filePath);
21
+ return (normalized.length > 0 &&
22
+ normalized !== '.' &&
23
+ !normalized.startsWith('../') &&
24
+ !path.posix.isAbsolute(normalized) &&
25
+ !path.win32.isAbsolute(filePath));
26
+ }
27
+ function isIgnoreControlPath(filePath) {
28
+ const normalized = posixWatchPath(filePath);
29
+ return normalized === '.gitignore' || normalized === '.gitnexusignore';
30
+ }
31
+ function isConfigControlPath(filePath) {
32
+ return posixWatchPath(filePath) === '.gitnexusrc';
33
+ }
34
+ function isAnalyzerOwnedWatchPath(filePath) {
35
+ const normalized = posixWatchPath(filePath).replace(/\/+$/, '');
36
+ return normalized === GITNEXUS_DIR || normalized.startsWith(`${GITNEXUS_DIR}/`);
37
+ }
38
+ function repoRelativeWatchPath(repoPath, candidate) {
39
+ const relative = path.relative(repoPath, candidate).replace(/\\/g, '/');
40
+ if (!relative || relative.startsWith('../') || path.isAbsolute(relative))
41
+ return null;
42
+ return relative;
43
+ }
44
+ function setEnvironment(name, value) {
45
+ if (value === undefined)
46
+ delete process.env[name];
47
+ else
48
+ process.env[name] = value;
49
+ }
50
+ function positiveInteger(value, flag, maximum) {
51
+ if (value === undefined)
52
+ return undefined;
53
+ const parsed = Number(value);
54
+ if (!Number.isInteger(parsed) || parsed < 1)
55
+ throw new Error(`${flag} must be a positive integer`);
56
+ if (maximum !== undefined && parsed > maximum) {
57
+ throw new Error(`${flag} must not exceed ${maximum}`);
58
+ }
59
+ return parsed;
60
+ }
61
+ export async function resolveWatchOptions(repoPath, cli, baseline, reportIgnoredConfig = () => { }) {
62
+ const config = (await loadAnalyzeConfigStrict(repoPath)) ?? {};
63
+ const merged = mergeAnalyzeOptions(cli, config);
64
+ const unsupported = [
65
+ ['--force', cli.force],
66
+ ['--repair-fts', cli.repairFts],
67
+ ['--embeddings', cli.embeddings],
68
+ ['--drop-embeddings', cli.dropEmbeddings],
69
+ ['--skills', cli.skills],
70
+ ['--default-branch', cli.defaultBranch],
71
+ ['--skip-agents-md', cli.skipAgentsMd],
72
+ ['--skip-skills', cli.skipSkills],
73
+ ['--no-stats', cli.stats === false],
74
+ ['--self-commit', cli.selfCommit],
75
+ ['--index-only', cli.indexOnly],
76
+ ['--skip-git', cli.skipGit],
77
+ ['walCheckpointThreshold', cli.walCheckpointThreshold],
78
+ ['embeddingThreads', cli.embeddingThreads],
79
+ ['embeddingBatchSize', cli.embeddingBatchSize],
80
+ ['embeddingSubBatchSize', cli.embeddingSubBatchSize],
81
+ ['embeddingDevice', cli.embeddingDevice],
82
+ ['embeddingBaseUrl', cli.embeddingBaseUrl],
83
+ ['embeddingModel', cli.embeddingModel],
84
+ ['--embedding-auth-token', cli.embeddingAuthToken],
85
+ ['--embedding-dims', cli.embeddingDims],
86
+ ].filter(([, value]) => value !== undefined && value !== false);
87
+ if (unsupported.length > 0) {
88
+ throw new Error(`analyze --watch does not support ${unsupported.map(([name]) => name).join(', ')}`);
89
+ }
90
+ reportIgnoredConfig([
91
+ ['embeddings', config.embeddings],
92
+ ['dropEmbeddings', config.dropEmbeddings],
93
+ ['defaultBranch', config.defaultBranch],
94
+ ['skipAgentsMd', config.skipAgentsMd !== undefined],
95
+ ['skipSkills', config.skipSkills !== undefined],
96
+ ['stats', config.stats !== undefined],
97
+ ['walCheckpointThreshold', config.walCheckpointThreshold],
98
+ ['embeddingThreads', config.embeddingThreads],
99
+ ['embeddingBatchSize', config.embeddingBatchSize],
100
+ ['embeddingSubBatchSize', config.embeddingSubBatchSize],
101
+ ['embeddingDevice', config.embeddingDevice],
102
+ ['embeddingBaseUrl', config.embeddingBaseUrl],
103
+ ['embeddingModel', config.embeddingModel],
104
+ ]
105
+ .filter(([, value]) => value !== undefined && value !== false)
106
+ .map(([name]) => String(name)));
107
+ const branch = merged.branch === undefined ? undefined : validateBranchName(merged.branch, '--branch');
108
+ const workerPoolSize = positiveInteger(merged.workers, '--workers');
109
+ const workerTimeoutSeconds = positiveInteger(merged.workerTimeout, 'workerTimeout');
110
+ const maxFileSize = positiveInteger(merged.maxFileSize, 'maxFileSize', MAX_FILE_SIZE_KB);
111
+ setEnvironment('GITNEXUS_MAX_FILE_SIZE', maxFileSize === undefined ? baseline.maxFileSize : String(maxFileSize));
112
+ if (workerTimeoutSeconds !== undefined) {
113
+ process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS = String(workerTimeoutSeconds * 1000);
114
+ }
115
+ else {
116
+ setEnvironment('GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', baseline.workerTimeout);
117
+ }
118
+ setEnvironment('GITNEXUS_VERBOSE', merged.verbose ? '1' : baseline.verbose);
119
+ return {
120
+ pdg: merged.pdg,
121
+ branch,
122
+ registryName: merged.name,
123
+ allowDuplicateName: merged.allowDuplicateName,
124
+ workerPoolSize,
125
+ fetchWrappers: merged.fetchWrappers,
126
+ skipAgentsMd: true,
127
+ skipSkills: true,
128
+ noStats: true,
129
+ atomicIncremental: process.platform !== 'win32',
130
+ };
131
+ }
132
+ function refreshSummary(result, observedPaths, durationMs, lastSuccessfulRefreshAt) {
133
+ const measured = result.incrementalStats;
134
+ const changed = measured?.changedFiles ?? (result.alreadyUpToDate ? 0 : observedPaths.length);
135
+ const reparsed = measured?.reparsedFiles ??
136
+ (typeof result.pipelineResult?.reparsedFileCount === 'number'
137
+ ? result.pipelineResult.reparsedFileCount
138
+ : 0);
139
+ const dependents = measured?.affectedDependents ?? 0;
140
+ const mode = measured?.writeMode ?? (result.alreadyUpToDate ? 'no-op' : 'full');
141
+ return (`Refresh complete: ${changed} changed, ${reparsed} re-parsed, ` +
142
+ `${dependents} affected dependent(s), ${durationMs}ms, ${mode}; ` +
143
+ `last success ${lastSuccessfulRefreshAt}`);
144
+ }
145
+ async function waitUntilReady(watcher) {
146
+ await new Promise((resolve, reject) => {
147
+ const ready = () => {
148
+ watcher.off('error', failed);
149
+ resolve();
150
+ };
151
+ const failed = (error) => {
152
+ watcher.off('ready', ready);
153
+ reject(error);
154
+ };
155
+ watcher.once('ready', ready);
156
+ watcher.once('error', failed);
157
+ });
158
+ }
159
+ class WatchControlReloadError extends Error {
160
+ constructor(cause) {
161
+ super(cause instanceof Error ? cause.message : String(cause), { cause });
162
+ this.name = 'WatchControlReloadError';
163
+ }
164
+ }
165
+ export function shouldStopAfterWatchRefreshFailure(error, paths) {
166
+ return (paths.length > 0 &&
167
+ !(error instanceof WatchControlReloadError) &&
168
+ analyzeFailureMayHaveMutatedLiveIndex(error));
169
+ }
170
+ /** Start the real filesystem watcher with bounded, serialized refreshes. */
171
+ export async function startWatchFileLoop(repoPath, debounceMs, refresh, onError, onWatcherError = (error) => onError(error, [])) {
172
+ let ignorePath = await createWatchIgnorePredicate(repoPath);
173
+ let ignoreControlValid = true;
174
+ const queue = new WatchRefreshQueue(async (paths) => {
175
+ if (paths.some(isIgnoreControlPath) || !ignoreControlValid) {
176
+ const retryingInvalidControls = !ignoreControlValid;
177
+ try {
178
+ ignorePath = await createWatchIgnorePredicate(repoPath);
179
+ ignoreControlValid = true;
180
+ watcher.add(repoPath);
181
+ }
182
+ catch (error) {
183
+ ignoreControlValid = false;
184
+ throw new WatchControlReloadError(retryingInvalidControls
185
+ ? new Error('Ignore controls remain invalid; fix them before indexing more changes.', {
186
+ cause: error,
187
+ })
188
+ : error);
189
+ }
190
+ }
191
+ await refresh(paths);
192
+ }, onError, debounceMs, {
193
+ maxWaitMs: Math.max(2_000, debounceMs * 10),
194
+ maxPendingPaths: 1_000,
195
+ holdEventsUntilInitialRefresh: true,
196
+ isPriorityPath: (filePath) => isIgnoreControlPath(filePath) || isConfigControlPath(filePath),
197
+ });
198
+ const watcher = watch(repoPath, {
199
+ ignoreInitial: true,
200
+ atomic: true,
201
+ followSymlinks: false,
202
+ awaitWriteFinish: { stabilityThreshold: 100, pollInterval: 20 },
203
+ ignored: (candidate, stats) => {
204
+ const relative = repoRelativeWatchPath(repoPath, candidate);
205
+ if (relative !== null && isAnalyzerOwnedWatchPath(relative))
206
+ return true;
207
+ if (relative !== null && (isIgnoreControlPath(relative) || isConfigControlPath(relative))) {
208
+ return false;
209
+ }
210
+ return ignorePath(candidate, stats?.isDirectory() ?? false);
211
+ },
212
+ });
213
+ watcher.on('all', (event, changedPath) => {
214
+ if (event !== 'add' && event !== 'change' && event !== 'unlink')
215
+ return;
216
+ const relative = repoRelativeWatchPath(repoPath, changedPath);
217
+ if (relative && isRelevantWatchPath(relative) && !isAnalyzerOwnedWatchPath(relative)) {
218
+ queue.enqueue(relative);
219
+ }
220
+ });
221
+ watcher.on('error', (error) => {
222
+ // Chokidar can surface a transient EPERM on Windows while an ignored
223
+ // analyzer-owned path is replaced. Re-arm the root and force one bounded
224
+ // catch-up refresh so a missed event cannot leave the graph stale. Other
225
+ // watcher errors may mean coverage was lost and remain fatal.
226
+ if (TRANSIENT_WATCH_ERROR_CODES.has(error.code ?? '')) {
227
+ watcher.add(repoPath);
228
+ queue.enqueue(WATCH_FULL_REFRESH_PATH);
229
+ return;
230
+ }
231
+ onWatcherError(error);
232
+ });
233
+ try {
234
+ await waitUntilReady(watcher);
235
+ await queue.runInitial();
236
+ }
237
+ catch (error) {
238
+ await watcher.close();
239
+ await queue.close();
240
+ throw error;
241
+ }
242
+ return {
243
+ waitForIdle: () => queue.waitForIdle(),
244
+ close: async () => {
245
+ await watcher.close();
246
+ await queue.close();
247
+ },
248
+ };
249
+ }
250
+ export async function watchCommandWithRunnerIdentity(runnerIdentityAtBootstrap, inputPath, cliOptions = {}) {
251
+ if (await ensureHeap({ cleanForwardedTermination: true }))
252
+ return;
253
+ const requestedRepoPath = inputPath ? path.resolve(inputPath) : getGitRoot(process.cwd());
254
+ if (requestedRepoPath === null || !hasGitDir(requestedRepoPath)) {
255
+ cliError(' gitnexus analyze --watch requires a Git repository.');
256
+ process.exitCode = 1;
257
+ return;
258
+ }
259
+ const repoPath = await fs.realpath(requestedRepoPath);
260
+ const baselineEnvironment = {
261
+ maxFileSize: process.env.GITNEXUS_MAX_FILE_SIZE,
262
+ workerTimeout: process.env.GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS,
263
+ verbose: process.env.GITNEXUS_VERBOSE,
264
+ };
265
+ try {
266
+ let ignoredConfigSignature;
267
+ const reportIgnoredConfig = (names) => {
268
+ const signature = [...names].sort().join(',');
269
+ if (signature === ignoredConfigSignature)
270
+ return;
271
+ ignoredConfigSignature = signature;
272
+ if (names.length > 0) {
273
+ cliWarn(`Watch mode ignores unsupported .gitnexusrc settings: ${names.join(', ')}.`);
274
+ }
275
+ };
276
+ let debounceMs;
277
+ let analyzeOptions;
278
+ try {
279
+ debounceMs =
280
+ positiveInteger(cliOptions.debounce ?? String(DEFAULT_DEBOUNCE_MS), '--debounce', MAX_TIMER_DELAY_MS) ?? DEFAULT_DEBOUNCE_MS;
281
+ analyzeOptions = await resolveWatchOptions(repoPath, cliOptions, baselineEnvironment, reportIgnoredConfig);
282
+ }
283
+ catch (error) {
284
+ cliError(` ${error instanceof Error ? error.message : String(error)}`);
285
+ process.exitCode = 1;
286
+ return;
287
+ }
288
+ let stopWatching;
289
+ const stopped = new Promise((resolve) => {
290
+ stopWatching = resolve;
291
+ });
292
+ const stop = () => stopWatching();
293
+ process.once('SIGINT', stop);
294
+ process.once('SIGTERM', stop);
295
+ try {
296
+ let loop;
297
+ let fatalRefreshError;
298
+ let configControlValid = true;
299
+ let lastSuccessfulRefreshAt;
300
+ try {
301
+ loop = await startWatchFileLoop(repoPath, debounceMs, async (paths) => {
302
+ if (paths.some(isConfigControlPath) || !configControlValid) {
303
+ const retryingInvalidConfig = !configControlValid;
304
+ try {
305
+ analyzeOptions = await resolveWatchOptions(repoPath, cliOptions, baselineEnvironment, reportIgnoredConfig);
306
+ configControlValid = true;
307
+ }
308
+ catch (error) {
309
+ configControlValid = false;
310
+ throw new WatchControlReloadError(retryingInvalidConfig
311
+ ? new Error('Configuration remains invalid; fix it before indexing more changes.', {
312
+ cause: error,
313
+ })
314
+ : error);
315
+ }
316
+ }
317
+ const startedAt = Date.now();
318
+ const result = await runFullAnalysis(repoPath, analyzeOptions, {
319
+ onProgress: () => { },
320
+ onLog: process.env.GITNEXUS_VERBOSE === '1'
321
+ ? (message) => cliInfo(` ${message}`)
322
+ : undefined,
323
+ }, runnerIdentityAtBootstrap);
324
+ lastSuccessfulRefreshAt = new Date().toISOString();
325
+ if (paths.length === 0) {
326
+ cliInfo(result.alreadyUpToDate
327
+ ? `Watching ${repoPath}; index is up to date.`
328
+ : `Watching ${repoPath}; initial index ready in ${Date.now() - startedAt}ms.`);
329
+ }
330
+ else {
331
+ cliInfo(refreshSummary(result, paths, Date.now() - startedAt, lastSuccessfulRefreshAt));
332
+ }
333
+ }, (error, paths) => {
334
+ const detail = paths.length > 0 ? ` (${paths.length} queued path(s))` : '';
335
+ if (shouldStopAfterWatchRefreshFailure(error, paths)) {
336
+ fatalRefreshError = error;
337
+ cliError(`Refresh failed${detail}: ${error instanceof Error ? error.message : String(error)}. ` +
338
+ 'Watch mode is stopping because the live index may have been updated in place.');
339
+ stopWatching();
340
+ return;
341
+ }
342
+ const lastSuccess = lastSuccessfulRefreshAt ?? 'none yet';
343
+ cliWarn(`Refresh failed${detail}: ${error instanceof Error ? error.message : String(error)}. ` +
344
+ `Retry scheduled; last success ${lastSuccess}.`);
345
+ }, (error) => {
346
+ fatalRefreshError = error;
347
+ cliError(`Watcher failed: ${error instanceof Error ? error.message : String(error)}. ` +
348
+ 'Watch mode is stopping.');
349
+ stopWatching();
350
+ });
351
+ }
352
+ catch (error) {
353
+ cliError(` Unable to start watcher: ${error instanceof Error ? error.message : String(error)}`);
354
+ process.exitCode = 1;
355
+ return;
356
+ }
357
+ await stopped;
358
+ await loop.close();
359
+ if (fatalRefreshError !== undefined)
360
+ process.exitCode = 1;
361
+ }
362
+ finally {
363
+ process.removeListener('SIGINT', stop);
364
+ process.removeListener('SIGTERM', stop);
365
+ }
366
+ }
367
+ finally {
368
+ setEnvironment('GITNEXUS_MAX_FILE_SIZE', baselineEnvironment.maxFileSize);
369
+ setEnvironment('GITNEXUS_WORKER_SUB_BATCH_TIMEOUT_MS', baselineEnvironment.workerTimeout);
370
+ setEnvironment('GITNEXUS_VERBOSE', baselineEnvironment.verbose);
371
+ }
372
+ }
package/dist/cli/wiki.js CHANGED
@@ -13,6 +13,7 @@ import { getStoragePaths, loadMeta, loadCLIConfig, saveCLIConfig, } from '../sto
13
13
  import { WikiGenerator } from '../core/wiki/generator.js';
14
14
  import { MINIMAX_MODEL_IDS, MINIMAX_OPENAI_BASE_URLS, parseLLMAllowedInsecureHttpHosts, resolveLLMConfig, } from '../core/wiki/llm-client.js';
15
15
  import { detectCursorCLI } from '../core/wiki/cursor-client.js';
16
+ import { detectGrokCLI } from '../core/wiki/grok-client.js';
16
17
  import { detectLocalCLI } from '../core/wiki/local-cli-client.js';
17
18
  import { logger } from '../core/logger.js';
18
19
  function parsePositiveIntegerOption(value, flag, multiplier = 1) {
@@ -32,7 +33,8 @@ function isLocalProvider(provider) {
32
33
  return (provider === 'cursor' ||
33
34
  provider === 'claude' ||
34
35
  provider === 'codex' ||
35
- provider === 'opencode');
36
+ provider === 'opencode' ||
37
+ provider === 'grok');
36
38
  }
37
39
  function localModelConfigKey(provider) {
38
40
  if (provider === 'cursor')
@@ -43,6 +45,8 @@ function localModelConfigKey(provider) {
43
45
  return 'codexModel';
44
46
  if (provider === 'opencode')
45
47
  return 'opencodeModel';
48
+ if (provider === 'grok')
49
+ return 'grokModel';
46
50
  throw new Error(`Unsupported local provider: ${provider}`);
47
51
  }
48
52
  /**
@@ -244,7 +248,7 @@ const wikiCommandImpl = async (inputPath, options) => {
244
248
  if (!llmConfig.apiKey && !isLocalProvider(llmConfig.provider)) {
245
249
  console.log(' Error: No LLM API key found.');
246
250
  console.log(' Set MINIMAX_API_KEY, GITNEXUS_API_KEY, or OPENAI_API_KEY,');
247
- console.log(' or pass --api-key <key>, or use --provider cursor|claude|codex|opencode.\n');
251
+ console.log(' or pass --api-key <key>, or use --provider cursor|claude|codex|opencode|grok.\n');
248
252
  process.exitCode = 1;
249
253
  return;
250
254
  }
@@ -258,6 +262,7 @@ const wikiCommandImpl = async (inputPath, options) => {
258
262
  const hasClaude = detectLocalCLI('claude');
259
263
  const hasCodex = detectLocalCLI('codex');
260
264
  const hasOpenCode = detectLocalCLI('opencode');
265
+ const hasGrok = detectGrokCLI();
261
266
  const localChoices = [];
262
267
  // Provider selection
263
268
  console.log(' [1] OpenAI (api.openai.com)');
@@ -299,6 +304,14 @@ const wikiCommandImpl = async (inputPath, options) => {
299
304
  });
300
305
  console.log(` [${choice}] OpenCode CLI (local, uses your OpenCode login/config)`);
301
306
  }
307
+ if (hasGrok) {
308
+ const choice = String(nextChoice++);
309
+ localChoices.push({
310
+ choice,
311
+ provider: 'grok',
312
+ });
313
+ console.log(` [${choice}] Grok CLI (local, uses your Grok Build login)`);
314
+ }
302
315
  console.log('');
303
316
  const maxChoice = String(nextChoice - 1);
304
317
  const choice = await prompt(` Select provider (1/${maxChoice}): `);
@@ -14,8 +14,19 @@ export interface IgnoreOptions {
14
14
  noGitignore?: boolean;
15
15
  /** Skip core.excludesFile and $GIT_COMMON_DIR/info/exclude. Defaults to GITNEXUS_NO_GLOBAL_IGNORE env var. */
16
16
  noGlobalIgnore?: boolean;
17
+ /** Fail repository-control reloads closed so long-lived watchers keep their prior predicate. */
18
+ strictRepoControlFiles?: boolean;
17
19
  }
18
20
  export declare const loadIgnoreRules: (repoPath: string, options?: IgnoreOptions) => Promise<Ignore | null>;
21
+ /**
22
+ * Build a synchronous predicate for long-lived filesystem watchers.
23
+ *
24
+ * Unlike {@link createIgnoreFilter}, callers pass ordinary absolute or
25
+ * repository-relative paths instead of path-scurry `Path` objects. The rule
26
+ * precedence deliberately mirrors the scanner: explicit negations win over
27
+ * hardcoded defaults unless a more-specific rule re-ignores the path.
28
+ */
29
+ export declare const createWatchIgnorePredicate: (repoPath: string, options?: IgnoreOptions) => Promise<(candidatePath: string, isDirectory?: boolean) => boolean>;
19
30
  /**
20
31
  * Create a glob-compatible ignore filter combining:
21
32
  * - .gitignore / .gitnexusignore patterns (via `ignore` package)
@@ -2,6 +2,7 @@ import ignore from 'ignore';
2
2
  import { existsSync } from 'fs';
3
3
  import fs from 'fs/promises';
4
4
  import nodePath from 'path';
5
+ import { readRepoControlFile } from './repo-control-file.js';
5
6
  import { logger } from '../core/logger.js';
6
7
  import { getCoreExcludesFilePath, getGitInfoExcludePath } from '../storage/git.js';
7
8
  const DEFAULT_IGNORE_LIST = new Set([
@@ -384,19 +385,55 @@ export const loadIgnoreRules = async (repoPath, options) => {
384
385
  const filenames = skipGitignore ? ['.gitnexusignore'] : ['.gitignore', '.gitnexusignore'];
385
386
  for (const filename of filenames) {
386
387
  try {
387
- const content = await fs.readFile(nodePath.join(repoPath, filename), 'utf-8');
388
+ const content = options?.strictRepoControlFiles
389
+ ? await readRepoControlFile(repoPath, filename)
390
+ : await fs.readFile(nodePath.join(repoPath, filename), 'utf-8');
391
+ if (content === null)
392
+ continue;
388
393
  ig.add(content);
389
394
  hasRules = true;
390
395
  }
391
396
  catch (err) {
392
397
  const code = err.code;
393
- if (code !== 'ENOENT') {
394
- logger.warn(` Warning: could not read ${filename}: ${err.message}`);
395
- }
398
+ if (!options?.strictRepoControlFiles && code === 'ENOENT')
399
+ continue;
400
+ if (options?.strictRepoControlFiles)
401
+ throw err;
402
+ logger.warn(` Warning: could not read ${filename}: ${err.message}`);
396
403
  }
397
404
  }
398
405
  return hasRules ? ig : null;
399
406
  };
407
+ /**
408
+ * Build a synchronous predicate for long-lived filesystem watchers.
409
+ *
410
+ * Unlike {@link createIgnoreFilter}, callers pass ordinary absolute or
411
+ * repository-relative paths instead of path-scurry `Path` objects. The rule
412
+ * precedence deliberately mirrors the scanner: explicit negations win over
413
+ * hardcoded defaults unless a more-specific rule re-ignores the path.
414
+ */
415
+ export const createWatchIgnorePredicate = async (repoPath, options) => {
416
+ const ig = await loadIgnoreRules(repoPath, { ...options, strictRepoControlFiles: true });
417
+ const repoRoot = nodePath.resolve(repoPath);
418
+ return (candidatePath, isDirectory = false) => {
419
+ const absolute = nodePath.isAbsolute(candidatePath)
420
+ ? nodePath.resolve(candidatePath)
421
+ : nodePath.resolve(repoRoot, candidatePath);
422
+ const rel = nodePath.relative(repoRoot, absolute).replace(/\\/g, '/');
423
+ if (!rel)
424
+ return false;
425
+ if (rel === '..' || rel.startsWith('../') || nodePath.isAbsolute(rel))
426
+ return true;
427
+ if (ig && hasExplicitUnignore(ig, rel) && !ig.ignores(isDirectory ? `${rel}/` : rel)) {
428
+ return false;
429
+ }
430
+ if (ig && ig.ignores(isDirectory ? `${rel}/` : rel))
431
+ return true;
432
+ if (isDirectory && isHardcodedIgnoredDirectoryAtPath(repoRoot, absolute))
433
+ return true;
434
+ return shouldIgnorePath(rel);
435
+ };
436
+ };
400
437
  /**
401
438
  * Walk ancestor segments of `rel` and check whether `.gitnexusignore`
402
439
  * (or `.gitignore`) contains an explicit `!pattern` negation that
@@ -0,0 +1,3 @@
1
+ export declare const MAX_REPO_CONTROL_FILE_BYTES: number;
2
+ /** Read a bounded, regular control file owned by the repository root. */
3
+ export declare function readRepoControlFile(repoRoot: string, filename: string): Promise<string | null>;
@@ -0,0 +1,115 @@
1
+ import fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ export const MAX_REPO_CONTROL_FILE_BYTES = 1024 * 1024;
4
+ /** Read a bounded, regular control file owned by the repository root. */
5
+ export async function readRepoControlFile(repoRoot, filename) {
6
+ const requestedRoot = path.resolve(repoRoot);
7
+ const requested = path.resolve(requestedRoot, filename);
8
+ const relative = path.relative(requestedRoot, requested);
9
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
10
+ throw new Error(`${filename} resolves outside the repository root`);
11
+ }
12
+ try {
13
+ const canonicalRoot = fs.realpathSync(requestedRoot);
14
+ const beforeOpen = fs.lstatSync(requested);
15
+ if (beforeOpen.isSymbolicLink())
16
+ throw new Error(`${filename} must not be a symbolic link`);
17
+ if (!beforeOpen.isFile())
18
+ throw new Error(`${filename} must be a regular file`);
19
+ if (beforeOpen.nlink !== 1)
20
+ throw new Error(`${filename} must not be a hard link`);
21
+ if (beforeOpen.size > MAX_REPO_CONTROL_FILE_BYTES) {
22
+ throw new Error(`${filename} exceeds ${MAX_REPO_CONTROL_FILE_BYTES} bytes`);
23
+ }
24
+ return await new Promise((resolve, reject) => {
25
+ const stream = fs.createReadStream(requested, {
26
+ flags: 'r',
27
+ start: 0,
28
+ end: MAX_REPO_CONTROL_FILE_BYTES,
29
+ autoClose: true,
30
+ });
31
+ const chunks = [];
32
+ let totalBytes = 0;
33
+ let validated = false;
34
+ let settled = false;
35
+ const finish = (value) => {
36
+ if (settled)
37
+ return;
38
+ settled = true;
39
+ resolve(value);
40
+ };
41
+ const fail = (error) => {
42
+ if (settled)
43
+ return;
44
+ settled = true;
45
+ reject(error);
46
+ };
47
+ stream.pause();
48
+ stream.once('open', (fd) => {
49
+ try {
50
+ const opened = fs.fstatSync(fd);
51
+ if (!opened.isFile())
52
+ throw new Error(`${filename} must be a regular file`);
53
+ if (opened.nlink !== 1)
54
+ throw new Error(`${filename} must not be a hard link`);
55
+ if (opened.size > MAX_REPO_CONTROL_FILE_BYTES) {
56
+ throw new Error(`${filename} exceeds ${MAX_REPO_CONTROL_FILE_BYTES} bytes`);
57
+ }
58
+ const entry = fs.lstatSync(requested);
59
+ if (entry.isSymbolicLink())
60
+ throw new Error(`${filename} must not be a symbolic link`);
61
+ if (!entry.isFile() ||
62
+ entry.nlink !== 1 ||
63
+ entry.dev !== opened.dev ||
64
+ entry.ino !== opened.ino) {
65
+ throw new Error(`${filename} moved or was replaced while being opened`);
66
+ }
67
+ const canonicalFile = fs.realpathSync(requested);
68
+ const canonicalRelative = path.relative(canonicalRoot, canonicalFile);
69
+ if (canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) {
70
+ throw new Error(`${filename} resolves outside the repository root`);
71
+ }
72
+ const canonical = fs.statSync(canonicalFile);
73
+ if (canonical.nlink !== 1 ||
74
+ canonical.dev !== opened.dev ||
75
+ canonical.ino !== opened.ino) {
76
+ throw new Error(`${filename} moved or was replaced while being opened`);
77
+ }
78
+ validated = true;
79
+ stream.resume();
80
+ }
81
+ catch (error) {
82
+ fail(error);
83
+ stream.destroy();
84
+ }
85
+ });
86
+ stream.on('data', (chunk) => {
87
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
88
+ totalBytes += bytes.length;
89
+ if (totalBytes > MAX_REPO_CONTROL_FILE_BYTES) {
90
+ fail(new Error(`${filename} exceeds ${MAX_REPO_CONTROL_FILE_BYTES} bytes`));
91
+ stream.destroy();
92
+ return;
93
+ }
94
+ chunks.push(bytes);
95
+ });
96
+ stream.once('end', () => {
97
+ if (!validated) {
98
+ fail(new Error(`${filename} could not be validated`));
99
+ return;
100
+ }
101
+ finish(Buffer.concat(chunks, totalBytes).toString('utf8'));
102
+ });
103
+ stream.once('error', fail);
104
+ stream.once('close', () => {
105
+ if (!settled)
106
+ fail(new Error(`${filename} closed before it could be read`));
107
+ });
108
+ });
109
+ }
110
+ catch (error) {
111
+ if (error.code === 'ENOENT')
112
+ return null;
113
+ throw error;
114
+ }
115
+ }