gitnexus 1.6.10 → 1.6.11-rc.10
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.
- package/README.md +43 -1
- package/dist/_shared/impact-risk.d.ts +37 -0
- package/dist/_shared/impact-risk.d.ts.map +1 -0
- package/dist/_shared/impact-risk.js +92 -0
- package/dist/_shared/impact-risk.js.map +1 -0
- package/dist/_shared/index.d.ts +2 -0
- package/dist/_shared/index.d.ts.map +1 -1
- package/dist/_shared/index.js +2 -0
- package/dist/_shared/index.js.map +1 -1
- package/dist/cli/ai-context.js +4 -4
- package/dist/cli/analyze-config.d.ts +2 -0
- package/dist/cli/analyze-config.js +16 -0
- package/dist/cli/analyze-options.d.ts +4 -0
- package/dist/cli/analyze.d.ts +17 -0
- package/dist/cli/analyze.js +43 -7
- package/dist/cli/eval-server.js +22 -5
- package/dist/cli/group.js +7 -1
- package/dist/cli/help-i18n.js +2 -0
- package/dist/cli/i18n/en.d.ts +11 -1
- package/dist/cli/i18n/en.js +11 -1
- package/dist/cli/i18n/resources.d.ts +21 -1
- package/dist/cli/i18n/zh-CN.d.ts +10 -0
- package/dist/cli/i18n/zh-CN.js +11 -1
- package/dist/cli/index.js +10 -3
- package/dist/cli/status.js +91 -6
- package/dist/cli/watch-queue.d.ts +41 -0
- package/dist/cli/watch-queue.js +184 -0
- package/dist/cli/watch.d.ts +20 -0
- package/dist/cli/watch.js +372 -0
- package/dist/cli/wiki.js +15 -2
- package/dist/config/ignore-service.d.ts +11 -0
- package/dist/config/ignore-service.js +41 -4
- package/dist/config/repo-control-file.d.ts +3 -0
- package/dist/config/repo-control-file.js +115 -0
- package/dist/core/group/config-parser.js +20 -2
- package/dist/core/group/cross-impact.d.ts +2 -1
- package/dist/core/group/cross-impact.js +33 -2
- package/dist/core/group/extractors/fs-utils.d.ts +2 -0
- package/dist/core/group/extractors/fs-utils.js +86 -0
- package/dist/core/group/extractors/graphql-extractor.d.ts +17 -0
- package/dist/core/group/extractors/graphql-extractor.js +652 -0
- package/dist/core/group/extractors/http-patterns/kotlin.js +522 -7
- package/dist/core/group/extractors/http-patterns/php.js +279 -11
- package/dist/core/group/extractors/manifest-extractor.d.ts +1 -1
- package/dist/core/group/extractors/manifest-extractor.js +1 -1
- package/dist/core/group/matching.js +8 -1
- package/dist/core/group/service.js +5 -1
- package/dist/core/group/storage.js +1 -0
- package/dist/core/group/sync.d.ts +3 -1
- package/dist/core/group/sync.js +43 -5
- package/dist/core/group/types.d.ts +19 -5
- package/dist/core/index-content-drift.d.ts +54 -0
- package/dist/core/index-content-drift.js +127 -0
- package/dist/core/index-freshness.d.ts +2 -2
- package/dist/core/index-freshness.js +13 -0
- package/dist/core/ingestion/filesystem-walker.d.ts +15 -5
- package/dist/core/ingestion/filesystem-walker.js +20 -3
- package/dist/core/ingestion/parsing-processor.d.ts +2 -0
- package/dist/core/ingestion/parsing-processor.js +5 -0
- package/dist/core/ingestion/pipeline-phases/parse-impl.d.ts +5 -0
- package/dist/core/ingestion/pipeline-phases/parse-impl.js +14 -0
- package/dist/core/ingestion/pipeline-phases/parse.d.ts +6 -0
- package/dist/core/ingestion/pipeline-phases/scan.js +25 -13
- package/dist/core/ingestion/pipeline.js +5 -1
- package/dist/core/ingestion/route-extractors/kotlin-const-resolver.d.ts +377 -0
- package/dist/core/ingestion/route-extractors/kotlin-const-resolver.js +1203 -0
- package/dist/core/ingestion/scope-extractor-bridge.js +8 -2
- package/dist/core/ingestion/scope-resolution/pipeline/phase.d.ts +2 -0
- package/dist/core/ingestion/scope-resolution/pipeline/phase.js +14 -2
- package/dist/core/ingestion/scope-resolution/pipeline/run.d.ts +2 -0
- package/dist/core/ingestion/scope-resolution/pipeline/run.js +11 -1
- package/dist/core/ingestion/scope-resolution/scope-extraction-failures.d.ts +14 -0
- package/dist/core/ingestion/scope-resolution/scope-extraction-failures.js +35 -0
- package/dist/core/ingestion/utils/symbol-labels.d.ts +2 -2
- package/dist/core/ingestion/utils/symbol-labels.js +2 -2
- package/dist/core/ingestion/workers/parse-worker.d.ts +6 -0
- package/dist/core/ingestion/workers/parse-worker.js +7 -1
- package/dist/core/ingestion/workers/result-merge.js +4 -1
- package/dist/core/run-analyze.d.ts +17 -0
- package/dist/core/run-analyze.js +117 -11
- package/dist/core/wiki/generator.js +8 -0
- package/dist/core/wiki/grok-client.d.ts +21 -0
- package/dist/core/wiki/grok-client.js +287 -0
- package/dist/core/wiki/llm-client.d.ts +1 -1
- package/dist/core/wiki/llm-client.js +5 -2
- package/dist/core/wiki/local-cli-client.d.ts +11 -0
- package/dist/core/wiki/local-cli-client.js +22 -9
- package/dist/mcp/local/local-backend.d.ts +26 -7
- package/dist/mcp/local/local-backend.js +219 -81
- package/dist/mcp/local/pdg-impact.d.ts +8 -4
- package/dist/mcp/local/pdg-impact.js +7 -2
- package/dist/mcp/repository-policy.d.ts +5 -1
- package/dist/mcp/repository-policy.js +48 -4
- package/dist/mcp/resources.js +2 -1
- package/dist/mcp/server.js +6 -5
- package/dist/mcp/tools.js +37 -23
- package/dist/storage/file-hash.d.ts +5 -0
- package/dist/storage/file-hash.js +16 -6
- package/dist/storage/git.d.ts +19 -8
- package/dist/storage/git.js +83 -24
- package/dist/storage/gitnexus-managed-paths.d.ts +36 -0
- package/dist/storage/gitnexus-managed-paths.js +46 -0
- package/dist/storage/parse-cache.js +19 -7
- package/dist/storage/repo-manager.d.ts +11 -1
- package/dist/storage/repo-manager.js +23 -2
- package/dist/storage/repo-meta.d.ts +29 -1
- package/dist/storage/repo-meta.js +1 -1
- package/dist/types/pipeline.d.ts +6 -0
- package/package.json +3 -1
- package/scripts/cross-platform-shard.ts +4 -2
- package/scripts/cross-platform-tests.ts +7 -1
- package/skills/gitnexus-cli.md +11 -3
- package/skills/gitnexus-impact-analysis.md +9 -0
- package/web/assets/{agent-Dr4l5EOp.js → agent-BWAXPjC5.js} +106 -104
- package/web/assets/{index-2zdvEdzg.js → index-DDRie6_8.js} +3 -3
- package/web/index.html +1 -1
|
@@ -33,6 +33,14 @@ export declare const cliResources: {
|
|
|
33
33
|
readonly 'status.detached': "(detached HEAD)";
|
|
34
34
|
readonly 'status.workspaceIndexLabel': "Workspace index: last analyzed on '{{primary}}' (re-run gitnexus analyze to follow the current branch)";
|
|
35
35
|
readonly 'status.status': "Status";
|
|
36
|
+
readonly 'status.indexContentCurrent': "Index content: matches all {{count}} covered file(s)";
|
|
37
|
+
readonly 'status.indexContentDrifted': "Index content: {{changed}} changed, {{added}} added, {{deleted}} deleted";
|
|
38
|
+
readonly 'status.indexContentMore': " ...and {{count}} more {{label}}";
|
|
39
|
+
readonly 'status.indexContentUnmeasurable': "Index content: not comparable ({{reason}}); fell back to the working-tree check";
|
|
40
|
+
readonly 'status.indexContentScanFailed': "Index content: coverage scan failed; treating the index as stale";
|
|
41
|
+
readonly 'status.driftChanged': "changed";
|
|
42
|
+
readonly 'status.driftAdded': "added";
|
|
43
|
+
readonly 'status.driftDeleted': "deleted";
|
|
36
44
|
readonly 'status.upToDate': "✅ up-to-date";
|
|
37
45
|
readonly 'status.stale': "⚠️ stale (re-run gitnexus analyze)";
|
|
38
46
|
readonly 'clean.deleteAll': "This will delete GitNexus indexes for {{count}} repo(s):";
|
|
@@ -172,6 +180,8 @@ export declare const cliResources: {
|
|
|
172
180
|
readonly 'help.option.analyze.embeddingBatchSize': "Number of nodes per embedding batch";
|
|
173
181
|
readonly 'help.option.analyze.embeddingSubBatchSize': "Number of chunks per embedding model call";
|
|
174
182
|
readonly 'help.option.analyze.embeddingDevice': "Embedding device: auto, cpu, dml, cuda, or wasm";
|
|
183
|
+
readonly 'help.option.analyze.watch': "Keep the index current with serialized incremental refreshes";
|
|
184
|
+
readonly 'help.option.analyze.debounce': "Watch quiet period before refreshing (milliseconds)";
|
|
175
185
|
readonly 'help.option.index.force': "Register even if index metadata is missing (stats will be empty)";
|
|
176
186
|
readonly 'help.option.index.allowNonGit': "Allow registering folders that are not Git repositories";
|
|
177
187
|
readonly 'help.option.port': "Port number";
|
|
@@ -185,7 +195,7 @@ export declare const cliResources: {
|
|
|
185
195
|
readonly 'help.option.clean.branch': "Delete only the named branch index (not the workspace index)";
|
|
186
196
|
readonly 'help.option.clean.lbugSidecars': "Clean parked LadybugDB recovery sidecars (missing-shadow WAL quarantines and dirty-recovery parks)";
|
|
187
197
|
readonly 'help.option.wiki.force': "Force full regeneration even if up to date";
|
|
188
|
-
readonly 'help.option.wiki.provider': "LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, or
|
|
198
|
+
readonly 'help.option.wiki.provider': "LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)";
|
|
189
199
|
readonly 'help.option.wiki.model': "LLM model or deployment name (default: MiniMax-M3)";
|
|
190
200
|
readonly 'help.option.wiki.baseUrl': "LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1";
|
|
191
201
|
readonly 'help.option.wiki.apiKey': "LLM API key or Azure api-key (saved to ~/.gitnexus/config.json)";
|
|
@@ -287,6 +297,14 @@ export declare const cliResources: {
|
|
|
287
297
|
'status.detached': string;
|
|
288
298
|
'status.workspaceIndexLabel': string;
|
|
289
299
|
'status.status': string;
|
|
300
|
+
'status.indexContentCurrent': string;
|
|
301
|
+
'status.indexContentDrifted': string;
|
|
302
|
+
'status.indexContentMore': string;
|
|
303
|
+
'status.indexContentUnmeasurable': string;
|
|
304
|
+
'status.indexContentScanFailed': string;
|
|
305
|
+
'status.driftChanged': string;
|
|
306
|
+
'status.driftAdded': string;
|
|
307
|
+
'status.driftDeleted': string;
|
|
290
308
|
'status.upToDate': string;
|
|
291
309
|
'status.stale': string;
|
|
292
310
|
'clean.deleteAll': string;
|
|
@@ -426,6 +444,8 @@ export declare const cliResources: {
|
|
|
426
444
|
'help.option.analyze.embeddingBatchSize': string;
|
|
427
445
|
'help.option.analyze.embeddingSubBatchSize': string;
|
|
428
446
|
'help.option.analyze.embeddingDevice': string;
|
|
447
|
+
'help.option.analyze.watch': string;
|
|
448
|
+
'help.option.analyze.debounce': string;
|
|
429
449
|
'help.option.index.force': string;
|
|
430
450
|
'help.option.index.allowNonGit': string;
|
|
431
451
|
'help.option.port': string;
|
package/dist/cli/i18n/zh-CN.d.ts
CHANGED
|
@@ -32,6 +32,14 @@ export declare const zhCN: {
|
|
|
32
32
|
'status.detached': string;
|
|
33
33
|
'status.workspaceIndexLabel': string;
|
|
34
34
|
'status.status': string;
|
|
35
|
+
'status.indexContentCurrent': string;
|
|
36
|
+
'status.indexContentDrifted': string;
|
|
37
|
+
'status.indexContentMore': string;
|
|
38
|
+
'status.indexContentUnmeasurable': string;
|
|
39
|
+
'status.indexContentScanFailed': string;
|
|
40
|
+
'status.driftChanged': string;
|
|
41
|
+
'status.driftAdded': string;
|
|
42
|
+
'status.driftDeleted': string;
|
|
35
43
|
'status.upToDate': string;
|
|
36
44
|
'status.stale': string;
|
|
37
45
|
'clean.deleteAll': string;
|
|
@@ -171,6 +179,8 @@ export declare const zhCN: {
|
|
|
171
179
|
'help.option.analyze.embeddingBatchSize': string;
|
|
172
180
|
'help.option.analyze.embeddingSubBatchSize': string;
|
|
173
181
|
'help.option.analyze.embeddingDevice': string;
|
|
182
|
+
'help.option.analyze.watch': string;
|
|
183
|
+
'help.option.analyze.debounce': string;
|
|
174
184
|
'help.option.index.force': string;
|
|
175
185
|
'help.option.index.allowNonGit': string;
|
|
176
186
|
'help.option.port': string;
|
package/dist/cli/i18n/zh-CN.js
CHANGED
|
@@ -32,6 +32,14 @@ export const zhCN = {
|
|
|
32
32
|
'status.detached': '(分离 HEAD)',
|
|
33
33
|
'status.workspaceIndexLabel': "工作区索引:最近在 '{{primary}}' 分支上分析(重新运行 gitnexus analyze 以跟随当前分支)",
|
|
34
34
|
'status.status': '状态',
|
|
35
|
+
'status.indexContentCurrent': '索引内容:与覆盖的全部 {{count}} 个文件一致',
|
|
36
|
+
'status.indexContentDrifted': '索引内容:{{changed}} 个已修改,{{added}} 个新增,{{deleted}} 个已删除',
|
|
37
|
+
'status.indexContentMore': ' ……另有 {{count}} 个 {{label}}',
|
|
38
|
+
'status.indexContentUnmeasurable': '索引内容:无法比对({{reason}}),已回退到工作区检查',
|
|
39
|
+
'status.indexContentScanFailed': '索引内容:覆盖扫描失败,按过期处理',
|
|
40
|
+
'status.driftChanged': '已修改',
|
|
41
|
+
'status.driftAdded': '新增',
|
|
42
|
+
'status.driftDeleted': '已删除',
|
|
35
43
|
'status.upToDate': '✅ 已是最新',
|
|
36
44
|
'status.stale': '⚠️ 已过期(重新运行 gitnexus analyze)',
|
|
37
45
|
'clean.deleteAll': '将删除 {{count}} 个仓库的 GitNexus 索引:',
|
|
@@ -171,6 +179,8 @@ export const zhCN = {
|
|
|
171
179
|
'help.option.analyze.embeddingBatchSize': '每个嵌入批次的节点数',
|
|
172
180
|
'help.option.analyze.embeddingSubBatchSize': '每次嵌入模型调用的分块数',
|
|
173
181
|
'help.option.analyze.embeddingDevice': '嵌入设备:auto、cpu、dml、cuda 或 wasm',
|
|
182
|
+
'help.option.analyze.watch': '监视本地源文件变更并串行执行增量刷新',
|
|
183
|
+
'help.option.analyze.debounce': '刷新前的静默等待时间(毫秒)',
|
|
174
184
|
'help.option.index.force': '即使缺少索引元数据也注册(统计为空)',
|
|
175
185
|
'help.option.index.allowNonGit': '允许注册非 Git 仓库文件夹',
|
|
176
186
|
'help.option.port': '端口号',
|
|
@@ -184,7 +194,7 @@ export const zhCN = {
|
|
|
184
194
|
'help.option.clean.branch': '仅删除指定分支的索引(不影响工作区索引)',
|
|
185
195
|
'help.option.clean.lbugSidecars': '清理已暂存的 LadybugDB 恢复 sidecar(missing-shadow WAL 隔离文件与 dirty-recovery 暂存文件)',
|
|
186
196
|
'help.option.wiki.force': '即使已是最新也强制完整重新生成',
|
|
187
|
-
'help.option.wiki.provider': 'LLM 提供商:minimax、openai、openrouter、azure、custom、cursor、claude、codex 或
|
|
197
|
+
'help.option.wiki.provider': 'LLM 提供商:minimax、openai、openrouter、azure、custom、cursor、claude、codex、opencode 或 grok(默认:minimax)',
|
|
188
198
|
'help.option.wiki.model': 'LLM 模型或 deployment 名称(默认:MiniMax-M3)',
|
|
189
199
|
'help.option.wiki.baseUrl': 'LLM API base URL。Azure v1:https://{resource}.openai.azure.com/openai/v1',
|
|
190
200
|
'help.option.wiki.apiKey': 'LLM API key 或 Azure api-key(保存到 ~/.gitnexus/config.json)',
|
package/dist/cli/index.js
CHANGED
|
@@ -36,6 +36,8 @@ let dimsEnvCaptured = false;
|
|
|
36
36
|
program
|
|
37
37
|
.command('analyze [path]')
|
|
38
38
|
.description('Index a repository (full analysis)')
|
|
39
|
+
.option('--watch', 'Keep the index current with serialized incremental refreshes')
|
|
40
|
+
.option('--debounce <ms>', 'Watch quiet period before refreshing (default: 300 milliseconds)')
|
|
39
41
|
.option('-f, --force', 'Force full re-index even if up to date')
|
|
40
42
|
.option('--repair-fts', 'Repair/rebuild search FTS indexes without full re-analysis')
|
|
41
43
|
.option('--embeddings [limit]', 'Enable embedding generation for semantic search (off by default). ' +
|
|
@@ -84,6 +86,11 @@ program
|
|
|
84
86
|
'Must match what the index was built with. Overrides GITNEXUS_EMBEDDING_DIMS.')
|
|
85
87
|
.addHelpText('after', () => t('help.analyze.environment'))
|
|
86
88
|
.hook('preAction', (thisCommand) => {
|
|
89
|
+
const analyzeOpts = thisCommand.opts();
|
|
90
|
+
if (analyzeOpts['debounce'] !== undefined && analyzeOpts['watch'] !== true) {
|
|
91
|
+
process.stderr.write('\n --debounce requires --watch\n\n');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
87
94
|
// ONLY GITNEXUS_EMBEDDING_DIMS must be set here: schema.ts reads it at
|
|
88
95
|
// module-load time during the lazy import('./analyze.js') below (via the
|
|
89
96
|
// static chain analyze.ts → run-analyze.ts → schema.ts), so deferring to
|
|
@@ -91,7 +98,7 @@ program
|
|
|
91
98
|
// lazily at runtime (readConfig), so analyzeCommandImpl is their sole
|
|
92
99
|
// setter — keeping them out of this hook means they fall under the impl's
|
|
93
100
|
// env snapshot/restore and don't leak across in-process invocations.
|
|
94
|
-
const dimsOpt =
|
|
101
|
+
const dimsOpt = analyzeOpts['embeddingDims'];
|
|
95
102
|
if (dimsOpt !== undefined) {
|
|
96
103
|
// Validate + normalize BEFORE writing the env var: schema.ts throws on a
|
|
97
104
|
// bad value at module-load, which — on the synchronous program.parse()
|
|
@@ -122,7 +129,7 @@ program
|
|
|
122
129
|
process.env.GITNEXUS_EMBEDDING_DIMS = dimsEnvBaseline;
|
|
123
130
|
}
|
|
124
131
|
})
|
|
125
|
-
.action(createAnalyzerLbugLazyAction(() => import('../core/analyzer-identity.js'), () => import('./analyze.js'), '
|
|
132
|
+
.action(createAnalyzerLbugLazyAction(() => import('../core/analyzer-identity.js'), () => import('./analyze.js'), 'analyzeOrWatchCommandWithRunnerIdentity', import.meta.url));
|
|
126
133
|
program
|
|
127
134
|
.command('index [path...]')
|
|
128
135
|
.description('Register an existing .gitnexus/ folder into the global registry (no re-analysis needed)')
|
|
@@ -187,7 +194,7 @@ program
|
|
|
187
194
|
.command('wiki [path]')
|
|
188
195
|
.description('Generate repository wiki from knowledge graph')
|
|
189
196
|
.option('-f, --force', 'Force full regeneration even if up to date')
|
|
190
|
-
.option('--provider <provider>', 'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, or
|
|
197
|
+
.option('--provider <provider>', 'LLM provider: minimax, openai, openrouter, azure, custom, cursor, claude, codex, opencode, or grok (default: minimax)')
|
|
191
198
|
.option('--model <model>', 'LLM model or deployment name (default: MiniMax-M3)')
|
|
192
199
|
.option('--base-url <url>', 'LLM API base URL. Azure v1: https://{resource}.openai.azure.com/openai/v1')
|
|
193
200
|
.option('--api-key <key>', 'LLM API key or Azure api-key (saved to ~/.gitnexus/config.json)')
|
package/dist/cli/status.js
CHANGED
|
@@ -8,7 +8,64 @@ import { findRepo, getStoragePaths, loadMeta, hasKuzuIndex } from '../storage/re
|
|
|
8
8
|
import { getCurrentCommit, getCurrentBranch, isGitRepo, getGitRoot, isWorkingTreeDirty, } from '../storage/git.js';
|
|
9
9
|
import { analyzerRunnerIdentitiesEqual, resolveAnalyzerRunnerIdentity, } from '../core/analyzer-identity.js';
|
|
10
10
|
import { getIndexIncompleteReasons } from '../core/index-freshness.js';
|
|
11
|
+
import { detectIndexContentDrift } from '../core/index-content-drift.js';
|
|
11
12
|
import { t } from './i18n/index.js';
|
|
13
|
+
/** How many drifted paths the report names before summarizing the rest. */
|
|
14
|
+
const DRIFT_SAMPLE_LIMIT = 10;
|
|
15
|
+
/**
|
|
16
|
+
* Machine-readable form of the per-file comparison. `'not-checked'` is its own
|
|
17
|
+
* value rather than a silent omission: it says the index was already stale on
|
|
18
|
+
* metadata alone, so the scan was skipped, which is not the same claim as a
|
|
19
|
+
* scan that ran and found nothing.
|
|
20
|
+
*/
|
|
21
|
+
const describeContentDrift = (drift) => {
|
|
22
|
+
if (!drift)
|
|
23
|
+
return { status: 'not-checked' };
|
|
24
|
+
if (drift.kind === 'current') {
|
|
25
|
+
return { status: 'current', coveredFiles: drift.coveredFileCount };
|
|
26
|
+
}
|
|
27
|
+
if (drift.kind === 'unmeasurable') {
|
|
28
|
+
return { status: 'unmeasurable', reason: drift.reason };
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
status: 'drifted',
|
|
32
|
+
counts: {
|
|
33
|
+
changed: drift.changed.length,
|
|
34
|
+
added: drift.added.length,
|
|
35
|
+
deleted: drift.deleted.length,
|
|
36
|
+
},
|
|
37
|
+
changed: drift.changed.slice(0, DRIFT_SAMPLE_LIMIT),
|
|
38
|
+
added: drift.added.slice(0, DRIFT_SAMPLE_LIMIT),
|
|
39
|
+
deleted: drift.deleted.slice(0, DRIFT_SAMPLE_LIMIT),
|
|
40
|
+
truncated: {
|
|
41
|
+
changed: drift.changed.length > DRIFT_SAMPLE_LIMIT,
|
|
42
|
+
added: drift.added.length > DRIFT_SAMPLE_LIMIT,
|
|
43
|
+
deleted: drift.deleted.length > DRIFT_SAMPLE_LIMIT,
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
/** Escape control characters in repo-relative paths before printing. */
|
|
48
|
+
const formatDriftPath = (rel) => /[\u0000-\u001f\u007f]/.test(rel) ? JSON.stringify(rel) : rel;
|
|
49
|
+
const printDriftDetail = (drift) => {
|
|
50
|
+
console.log(t('status.indexContentDrifted', {
|
|
51
|
+
changed: drift.changed.length,
|
|
52
|
+
added: drift.added.length,
|
|
53
|
+
deleted: drift.deleted.length,
|
|
54
|
+
}));
|
|
55
|
+
const labelled = [
|
|
56
|
+
[t('status.driftChanged'), drift.changed],
|
|
57
|
+
[t('status.driftAdded'), drift.added],
|
|
58
|
+
[t('status.driftDeleted'), drift.deleted],
|
|
59
|
+
];
|
|
60
|
+
for (const [label, paths] of labelled) {
|
|
61
|
+
for (const p of paths.slice(0, DRIFT_SAMPLE_LIMIT)) {
|
|
62
|
+
console.log(` ${label}: ${formatDriftPath(p)}`);
|
|
63
|
+
}
|
|
64
|
+
const remaining = paths.length - DRIFT_SAMPLE_LIMIT;
|
|
65
|
+
if (remaining > 0)
|
|
66
|
+
console.log(t('status.indexContentMore', { count: remaining, label }));
|
|
67
|
+
}
|
|
68
|
+
};
|
|
12
69
|
export const statusCommand = async (options = {}) => {
|
|
13
70
|
const cwd = process.cwd();
|
|
14
71
|
if (!isGitRepo(cwd)) {
|
|
@@ -63,13 +120,26 @@ export const statusCommand = async (options = {}) => {
|
|
|
63
120
|
const currentRunnerIdentity = resolveAnalyzerRunnerIdentity(import.meta.url);
|
|
64
121
|
const runnerIdentityIsCurrent = analyzerRunnerIdentitiesEqual(activeMeta.runnerIdentity, currentRunnerIdentity);
|
|
65
122
|
const incompleteReasons = getIndexIncompleteReasons(activeMeta);
|
|
66
|
-
|
|
67
|
-
// so a repo with uncommitted source changes is stale even at the same commit.
|
|
68
|
-
// Skip the check for non-git folders (currentCommit === '') to match analyze.
|
|
69
|
-
const isUpToDate = currentCommit === activeMeta.lastCommit &&
|
|
123
|
+
const metadataIsCurrent = currentCommit === activeMeta.lastCommit &&
|
|
70
124
|
runnerIdentityIsCurrent &&
|
|
71
|
-
incompleteReasons.length === 0
|
|
72
|
-
|
|
125
|
+
incompleteReasons.length === 0;
|
|
126
|
+
// A matching HEAD is not enough: `analyze` re-indexes changed content at the
|
|
127
|
+
// same commit, so the files the index covers must still be compared against
|
|
128
|
+
// disk. Only worth the scan once the cheap metadata checks agree, and skipped
|
|
129
|
+
// for non-git folders (currentCommit === '') to match analyze.
|
|
130
|
+
const contentDrift = metadataIsCurrent && currentCommit !== ''
|
|
131
|
+
? await detectIndexContentDrift(repo.repoPath, activeMeta.fileHashes, activeMeta.indexCoverage)
|
|
132
|
+
: undefined;
|
|
133
|
+
// The repo-wide dirty flag survives only as the fallback for metadata written
|
|
134
|
+
// before `fileHashes` existed. Where the per-file comparison can run it
|
|
135
|
+
// decides, so a file the index does not cover no longer pins a byte-current
|
|
136
|
+
// index to a "stale" verdict that `analyze` is powerless to clear (#3077).
|
|
137
|
+
const contentIsCurrent = contentDrift === undefined ||
|
|
138
|
+
contentDrift.kind === 'current' ||
|
|
139
|
+
(contentDrift.kind === 'unmeasurable' &&
|
|
140
|
+
contentDrift.reason === 'no-file-hashes' &&
|
|
141
|
+
!isWorkingTreeDirty(repo.repoPath));
|
|
142
|
+
const isUpToDate = metadataIsCurrent && contentIsCurrent;
|
|
73
143
|
if (options.json) {
|
|
74
144
|
console.log(JSON.stringify({
|
|
75
145
|
schemaVersion: 1,
|
|
@@ -87,6 +157,7 @@ export const statusCommand = async (options = {}) => {
|
|
|
87
157
|
commit: currentCommit,
|
|
88
158
|
runnerIdentity: currentRunnerIdentity,
|
|
89
159
|
},
|
|
160
|
+
contentDrift: describeContentDrift(contentDrift),
|
|
90
161
|
status: isUpToDate ? 'up-to-date' : 'stale',
|
|
91
162
|
}));
|
|
92
163
|
return;
|
|
@@ -107,5 +178,19 @@ export const statusCommand = async (options = {}) => {
|
|
|
107
178
|
console.log(`Index incomplete reasons: ${JSON.stringify(incompleteReasons)}`);
|
|
108
179
|
}
|
|
109
180
|
console.log(`${t('status.currentRunnerIdentity')}: ${JSON.stringify(currentRunnerIdentity)}`);
|
|
181
|
+
if (contentDrift?.kind === 'current') {
|
|
182
|
+
console.log(t('status.indexContentCurrent', { count: contentDrift.coveredFileCount }));
|
|
183
|
+
}
|
|
184
|
+
else if (contentDrift?.kind === 'drifted') {
|
|
185
|
+
printDriftDetail(contentDrift);
|
|
186
|
+
}
|
|
187
|
+
else if (contentDrift?.kind === 'unmeasurable') {
|
|
188
|
+
if (contentDrift.reason === 'scan-failed') {
|
|
189
|
+
console.log(t('status.indexContentScanFailed'));
|
|
190
|
+
}
|
|
191
|
+
else if (!isUpToDate) {
|
|
192
|
+
console.log(t('status.indexContentUnmeasurable', { reason: contentDrift.reason }));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
110
195
|
console.log(`${t('status.status')}: ${isUpToDate ? t('status.upToDate') : t('status.stale')}`);
|
|
111
196
|
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type WatchRefresh = (paths: readonly string[]) => Promise<void>;
|
|
2
|
+
export type WatchRefreshError = (error: unknown, paths: readonly string[]) => void;
|
|
3
|
+
export declare const WATCH_FULL_REFRESH_PATH = "*";
|
|
4
|
+
export interface WatchRefreshQueueOptions {
|
|
5
|
+
readonly maxWaitMs?: number;
|
|
6
|
+
readonly maxPendingPaths?: number;
|
|
7
|
+
readonly retryBaseDelayMs?: number;
|
|
8
|
+
readonly retryMaxDelayMs?: number;
|
|
9
|
+
readonly holdEventsUntilInitialRefresh?: boolean;
|
|
10
|
+
readonly isPriorityPath?: (filePath: string) => boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Debounces filesystem events and guarantees that refreshes never overlap. */
|
|
13
|
+
export declare class WatchRefreshQueue {
|
|
14
|
+
private readonly refresh;
|
|
15
|
+
private readonly onError;
|
|
16
|
+
private readonly debounceMs;
|
|
17
|
+
private readonly options;
|
|
18
|
+
private readonly pending;
|
|
19
|
+
private readonly idleWaiters;
|
|
20
|
+
private timer;
|
|
21
|
+
private active;
|
|
22
|
+
private closed;
|
|
23
|
+
private initialPending;
|
|
24
|
+
private firstPendingAt;
|
|
25
|
+
private overflowed;
|
|
26
|
+
private consecutiveFailures;
|
|
27
|
+
private retryNotBefore;
|
|
28
|
+
constructor(refresh: WatchRefresh, onError: WatchRefreshError, debounceMs: number, options?: WatchRefreshQueueOptions);
|
|
29
|
+
enqueue(filePath: string): void;
|
|
30
|
+
private addPendingPath;
|
|
31
|
+
/** Run the initial refresh while still queueing events that arrive during it. */
|
|
32
|
+
runInitial(): Promise<void>;
|
|
33
|
+
waitForIdle(): Promise<void>;
|
|
34
|
+
close(): Promise<void>;
|
|
35
|
+
private schedule;
|
|
36
|
+
private drain;
|
|
37
|
+
private runBatch;
|
|
38
|
+
private hasPendingWork;
|
|
39
|
+
private isIdle;
|
|
40
|
+
private resolveIdleWaiters;
|
|
41
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
export const WATCH_FULL_REFRESH_PATH = '*';
|
|
2
|
+
/** Debounces filesystem events and guarantees that refreshes never overlap. */
|
|
3
|
+
export class WatchRefreshQueue {
|
|
4
|
+
refresh;
|
|
5
|
+
onError;
|
|
6
|
+
debounceMs;
|
|
7
|
+
options;
|
|
8
|
+
pending = new Set();
|
|
9
|
+
idleWaiters = new Set();
|
|
10
|
+
timer;
|
|
11
|
+
active;
|
|
12
|
+
closed = false;
|
|
13
|
+
initialPending = false;
|
|
14
|
+
firstPendingAt;
|
|
15
|
+
overflowed = false;
|
|
16
|
+
consecutiveFailures = 0;
|
|
17
|
+
retryNotBefore;
|
|
18
|
+
constructor(refresh, onError, debounceMs, options = {}) {
|
|
19
|
+
this.refresh = refresh;
|
|
20
|
+
this.onError = onError;
|
|
21
|
+
this.debounceMs = debounceMs;
|
|
22
|
+
this.options = options;
|
|
23
|
+
this.initialPending = options.holdEventsUntilInitialRefresh === true;
|
|
24
|
+
}
|
|
25
|
+
enqueue(filePath) {
|
|
26
|
+
if (this.closed)
|
|
27
|
+
return;
|
|
28
|
+
this.addPendingPath(filePath);
|
|
29
|
+
this.firstPendingAt ??= Date.now();
|
|
30
|
+
if (!this.initialPending && this.active === undefined)
|
|
31
|
+
this.schedule();
|
|
32
|
+
}
|
|
33
|
+
addPendingPath(filePath) {
|
|
34
|
+
const maxPendingPaths = this.options.maxPendingPaths ?? 1_000;
|
|
35
|
+
const priority = this.options.isPriorityPath?.(filePath) === true;
|
|
36
|
+
if (this.pending.has(filePath)) {
|
|
37
|
+
// A duplicate does not increase memory use or imply that paths were dropped.
|
|
38
|
+
}
|
|
39
|
+
else if (this.pending.size < maxPendingPaths) {
|
|
40
|
+
this.pending.add(filePath);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
this.overflowed = true;
|
|
44
|
+
if (priority) {
|
|
45
|
+
const evictable = [...this.pending].find((pendingPath) => this.options.isPriorityPath?.(pendingPath) !== true);
|
|
46
|
+
if (evictable !== undefined) {
|
|
47
|
+
this.pending.delete(evictable);
|
|
48
|
+
this.pending.add(filePath);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Run the initial refresh while still queueing events that arrive during it. */
|
|
54
|
+
async runInitial() {
|
|
55
|
+
if (this.closed)
|
|
56
|
+
return;
|
|
57
|
+
if (this.active !== undefined)
|
|
58
|
+
throw new Error('Watch refresh is already running');
|
|
59
|
+
try {
|
|
60
|
+
await this.runBatch([], true);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
this.initialPending = false;
|
|
64
|
+
if (!this.closed && this.hasPendingWork())
|
|
65
|
+
this.schedule();
|
|
66
|
+
else
|
|
67
|
+
this.resolveIdleWaiters();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async waitForIdle() {
|
|
71
|
+
if (this.isIdle())
|
|
72
|
+
return;
|
|
73
|
+
await new Promise((resolve) => this.idleWaiters.add(resolve));
|
|
74
|
+
}
|
|
75
|
+
async close() {
|
|
76
|
+
this.closed = true;
|
|
77
|
+
if (this.timer !== undefined)
|
|
78
|
+
clearTimeout(this.timer);
|
|
79
|
+
this.timer = undefined;
|
|
80
|
+
this.pending.clear();
|
|
81
|
+
this.firstPendingAt = undefined;
|
|
82
|
+
this.overflowed = false;
|
|
83
|
+
this.consecutiveFailures = 0;
|
|
84
|
+
this.retryNotBefore = undefined;
|
|
85
|
+
// A refresh rejection is already surfaced through `onError` (or through
|
|
86
|
+
// runInitial). Closing from that handler can race the runBatch `finally`,
|
|
87
|
+
// so consume the same rejection here instead of reporting it twice.
|
|
88
|
+
await this.active?.catch(() => { });
|
|
89
|
+
this.resolveIdleWaiters();
|
|
90
|
+
}
|
|
91
|
+
schedule(retryDelayMs) {
|
|
92
|
+
if (this.timer !== undefined)
|
|
93
|
+
clearTimeout(this.timer);
|
|
94
|
+
const maxWaitMs = this.options.maxWaitMs ?? Math.max(this.debounceMs, 2_000);
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
if (retryDelayMs !== undefined)
|
|
97
|
+
this.retryNotBefore = now + retryDelayMs;
|
|
98
|
+
const elapsed = this.firstPendingAt === undefined ? 0 : now - this.firstPendingAt;
|
|
99
|
+
const debounced = Math.max(0, Math.min(this.debounceMs, maxWaitMs - elapsed));
|
|
100
|
+
// An event arriving mid-backoff merges into the pending batch but must not
|
|
101
|
+
// pull the retry earlier than the deadline the backoff already committed to.
|
|
102
|
+
const delay = retryDelayMs ??
|
|
103
|
+
(this.retryNotBefore === undefined
|
|
104
|
+
? debounced
|
|
105
|
+
: Math.max(debounced, this.retryNotBefore - now));
|
|
106
|
+
this.timer = setTimeout(() => {
|
|
107
|
+
this.timer = undefined;
|
|
108
|
+
void this.drain();
|
|
109
|
+
}, delay);
|
|
110
|
+
}
|
|
111
|
+
async drain() {
|
|
112
|
+
if (this.closed || this.active !== undefined || !this.hasPendingWork())
|
|
113
|
+
return;
|
|
114
|
+
const paths = [
|
|
115
|
+
...(this.overflowed ? [WATCH_FULL_REFRESH_PATH] : []),
|
|
116
|
+
...[...this.pending].sort(),
|
|
117
|
+
];
|
|
118
|
+
this.pending.clear();
|
|
119
|
+
this.firstPendingAt = undefined;
|
|
120
|
+
this.overflowed = false;
|
|
121
|
+
this.retryNotBefore = undefined;
|
|
122
|
+
await this.runBatch(paths, false);
|
|
123
|
+
}
|
|
124
|
+
async runBatch(paths, propagateError) {
|
|
125
|
+
let work;
|
|
126
|
+
try {
|
|
127
|
+
work = this.refresh(paths);
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
work = Promise.reject(error);
|
|
131
|
+
}
|
|
132
|
+
this.active = work;
|
|
133
|
+
let retryDelayMs;
|
|
134
|
+
try {
|
|
135
|
+
await work;
|
|
136
|
+
this.consecutiveFailures = 0;
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
if (propagateError)
|
|
140
|
+
throw error;
|
|
141
|
+
try {
|
|
142
|
+
await this.onError(error, paths);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
// Refresh failures are already handled here; a reporter must not
|
|
146
|
+
// reject the detached drain promise and become an unhandled rejection.
|
|
147
|
+
}
|
|
148
|
+
if (!this.closed) {
|
|
149
|
+
if (paths.includes(WATCH_FULL_REFRESH_PATH))
|
|
150
|
+
this.overflowed = true;
|
|
151
|
+
for (const filePath of paths) {
|
|
152
|
+
if (filePath !== WATCH_FULL_REFRESH_PATH)
|
|
153
|
+
this.addPendingPath(filePath);
|
|
154
|
+
}
|
|
155
|
+
this.firstPendingAt = Date.now();
|
|
156
|
+
this.consecutiveFailures++;
|
|
157
|
+
const base = this.options.retryBaseDelayMs ?? Math.max(250, this.debounceMs);
|
|
158
|
+
const maximum = this.options.retryMaxDelayMs ?? 30_000;
|
|
159
|
+
retryDelayMs = Math.min(maximum, base * 2 ** (this.consecutiveFailures - 1));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
if (this.active === work)
|
|
164
|
+
this.active = undefined;
|
|
165
|
+
if (!this.closed && !this.initialPending && this.hasPendingWork())
|
|
166
|
+
this.schedule(retryDelayMs);
|
|
167
|
+
else
|
|
168
|
+
this.resolveIdleWaiters();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
hasPendingWork() {
|
|
172
|
+
return this.overflowed || this.pending.size > 0;
|
|
173
|
+
}
|
|
174
|
+
isIdle() {
|
|
175
|
+
return this.active === undefined && this.timer === undefined && !this.hasPendingWork();
|
|
176
|
+
}
|
|
177
|
+
resolveIdleWaiters() {
|
|
178
|
+
if (!this.isIdle() && !this.closed)
|
|
179
|
+
return;
|
|
180
|
+
for (const resolve of this.idleWaiters)
|
|
181
|
+
resolve();
|
|
182
|
+
this.idleWaiters.clear();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type AnalyzeOptions as CoreAnalyzeOptions } from '../core/run-analyze.js';
|
|
2
|
+
import type { AnalyzerRunnerIdentity } from '../storage/repo-manager.js';
|
|
3
|
+
import type { AnalyzeOptions } from './analyze-options.js';
|
|
4
|
+
import { type WatchRefreshError } from './watch-queue.js';
|
|
5
|
+
export type WatchCliOptions = AnalyzeOptions;
|
|
6
|
+
export declare function isRelevantWatchPath(filePath: string): boolean;
|
|
7
|
+
export interface WatchEnvironmentBaseline {
|
|
8
|
+
readonly maxFileSize: string | undefined;
|
|
9
|
+
readonly workerTimeout: string | undefined;
|
|
10
|
+
readonly verbose: string | undefined;
|
|
11
|
+
}
|
|
12
|
+
export declare function resolveWatchOptions(repoPath: string, cli: WatchCliOptions, baseline: WatchEnvironmentBaseline, reportIgnoredConfig?: (names: readonly string[]) => void): Promise<CoreAnalyzeOptions>;
|
|
13
|
+
export interface WatchFileLoop {
|
|
14
|
+
readonly waitForIdle: () => Promise<void>;
|
|
15
|
+
readonly close: () => Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
export declare function shouldStopAfterWatchRefreshFailure(error: unknown, paths: readonly string[]): boolean;
|
|
18
|
+
/** Start the real filesystem watcher with bounded, serialized refreshes. */
|
|
19
|
+
export declare function startWatchFileLoop(repoPath: string, debounceMs: number, refresh: (paths: readonly string[]) => Promise<void>, onError: WatchRefreshError, onWatcherError?: (error: unknown) => void): Promise<WatchFileLoop>;
|
|
20
|
+
export declare function watchCommandWithRunnerIdentity(runnerIdentityAtBootstrap: AnalyzerRunnerIdentity, inputPath?: string, cliOptions?: WatchCliOptions): Promise<void>;
|