argo-search 2.9.7 → 2.9.8

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 (140) hide show
  1. package/README.en.md +58 -145
  2. package/README.es.md +58 -145
  3. package/README.ja.md +64 -151
  4. package/README.ko.md +63 -150
  5. package/README.md +65 -154
  6. package/SKILL.md +3 -3
  7. package/assets/readme/hero.svg +2 -2
  8. package/backends/domain_profiles.json +48 -0
  9. package/backends/engine_registry.yaml +118 -4
  10. package/backends/quota_profiles.json +92 -12
  11. package/backends/source_types_cn.json +27 -1
  12. package/bin/argo +74 -5
  13. package/bin/argo.js +33 -2
  14. package/config.yaml +219 -60
  15. package/docs/ENGINE_CATALOG.md +50 -39
  16. package/docs/MCP_SETUP.md +3 -1
  17. package/docs/RELEASE_NOTES_v2.9.8.md +73 -0
  18. package/engines/specs/cfets.yaml +41 -0
  19. package/engines/specs/defillama.yaml +38 -0
  20. package/engines/specs/firecrawl.yaml +1 -1
  21. package/engines/specs/fxtwitter.yaml +5 -0
  22. package/engines/specs/gleif.yaml +55 -0
  23. package/engines/specs/hkex.yaml +40 -0
  24. package/engines/specs/keenable_free.yaml +36 -0
  25. package/engines/specs/shfe.yaml +39 -0
  26. package/engines/specs/stackexchange.yaml +2 -2
  27. package/engines/specs/tavily_free.yaml +56 -0
  28. package/engines/specs/yuanbao.yaml +93 -0
  29. package/mcp/clients.yaml +32 -0
  30. package/package.json +6 -2
  31. package/packages/dsh-plugin/README.md +23 -5
  32. package/packages/dsh-plugin/dsh/client.js +304 -15
  33. package/packages/dsh-plugin/dsh/diagnostics.js +54 -0
  34. package/packages/dsh-plugin/dsh/index.js +112 -1875
  35. package/packages/dsh-plugin/dsh/native-tools.mjs +7 -1
  36. package/packages/dsh-plugin/dsh/runner.js +666 -0
  37. package/packages/dsh-plugin/dsh/wide-research.js +878 -0
  38. package/packages/dsh-plugin/package.json +2 -2
  39. package/packages/dsh-plugin/scripts/sync-local.sh +70 -0
  40. package/packages/dsh-plugin/test/client.test.js +175 -1
  41. package/packages/dsh-plugin/test/diagnostics.test.js +28 -0
  42. package/packages/dsh-plugin/test/index.test.js +44 -0
  43. package/scripts/adaptive.py +24 -1
  44. package/scripts/article_meta.py +131 -0
  45. package/scripts/budget.py +247 -0
  46. package/scripts/budget_cli.py +93 -0
  47. package/scripts/build_release_assets.sh +65 -0
  48. package/scripts/cache.py +60 -72
  49. package/scripts/ci_drift_gate.py +90 -0
  50. package/scripts/content_boundary.py +46 -0
  51. package/scripts/content_signals.py +26 -5
  52. package/scripts/cross_lingual_trace.py +293 -0
  53. package/scripts/dispatch_result.py +98 -0
  54. package/scripts/engine_dispatch.py +49 -86
  55. package/scripts/engine_env.py +5 -0
  56. package/scripts/engine_families.py +6 -0
  57. package/scripts/engine_policy.py +7 -7
  58. package/scripts/engines.py +21 -0
  59. package/scripts/engines_base.py +3 -3
  60. package/scripts/engines_builders.py +12 -0
  61. package/scripts/engines_builders_batch10.py +48 -3
  62. package/scripts/engines_builders_batch14.py +656 -0
  63. package/scripts/engines_builders_batch15.py +249 -0
  64. package/scripts/engines_builders_keyless.py +111 -0
  65. package/scripts/engines_builders_tech.py +0 -1
  66. package/scripts/evidence.py +31 -3
  67. package/scripts/evidence_loop.py +54 -22
  68. package/scripts/fetch_v3.py +25 -0
  69. package/scripts/gen_routing_snapshot.py +11 -0
  70. package/scripts/install.ps1 +18 -0
  71. package/scripts/install.sh +16 -1
  72. package/scripts/job.py +3 -3
  73. package/scripts/local_seek.py +41 -5
  74. package/scripts/macro_countries.py +15 -0
  75. package/scripts/matrix_search_eval.py +82 -0
  76. package/scripts/mcp_compact.py +234 -0
  77. package/scripts/mcp_handlers.py +60 -642
  78. package/scripts/mcp_launch.sh +10 -8
  79. package/scripts/mcp_local.py +241 -0
  80. package/scripts/mcp_server.py +19 -14
  81. package/scripts/mcp_setup.py +33 -9
  82. package/scripts/mcp_tools.py +1 -0
  83. package/scripts/mcp_transport.py +1 -1
  84. package/scripts/mcp_warm.py +127 -0
  85. package/scripts/output_transparency.py +200 -0
  86. package/scripts/paid_policy.py +98 -0
  87. package/scripts/quota.py +158 -13
  88. package/scripts/recovery_runner.py +150 -0
  89. package/scripts/replay_eval.py +18 -0
  90. package/scripts/research.py +37 -21
  91. package/scripts/research_expand.py +24 -1
  92. package/scripts/retry_policy.py +76 -0
  93. package/scripts/route.py +6 -6
  94. package/scripts/route_cache.py +18 -0
  95. package/scripts/route_combo.py +7 -0
  96. package/scripts/route_domains.py +32 -19
  97. package/scripts/search.py +15 -27
  98. package/scripts/search_attribution.py +100 -3
  99. package/scripts/search_cli.py +24 -11
  100. package/scripts/search_entry.py +7 -1
  101. package/scripts/search_output.py +99 -15
  102. package/scripts/search_pipeline.py +42 -193
  103. package/scripts/search_types.py +107 -9
  104. package/scripts/semantic_evidence.py +179 -3
  105. package/scripts/serp_guard.py +115 -7
  106. package/scripts/serp_kill_log.py +25 -2
  107. package/scripts/stats_cli.py +13 -1
  108. package/scripts/wide_research.py +45 -0
  109. package/scripts/zhihu_quota.py +139 -0
  110. package/sub-skills/local-search/bm25.py +27 -3
  111. package/sub-skills/local-search/cli_runner.py +331 -0
  112. package/sub-skills/local-search/parsers.py +328 -0
  113. package/sub-skills/local-search/search_v3.py +31 -674
  114. package/sub-skills/local-seek/scripts/seek_rank.py +7 -3
  115. package/assets/readme/settings-keys.png +0 -0
  116. package/assets/readme/settings-tools.png +0 -0
  117. package/scripts/image_vision/vision_probe +0 -0
  118. package/scripts/local_engines.py +0 -321
  119. package/sub-skills/ego-search/references/browser-runtime.md +0 -43
  120. package/sub-skills/ego-search/references/install.md +0 -67
  121. package/sub-skills/ego-search/references/original-ego-upgrade.md +0 -51
  122. package/sub-skills/ego-search/tests/test_query_normalize.py +0 -68
  123. package/sub-skills/js-run/tests/fixtures/challenge_like.js +0 -27
  124. package/sub-skills/js-run/tests/test_jsrun.py +0 -165
  125. package/sub-skills/local-seek/references/auto-behaviors.md +0 -24
  126. package/sub-skills/local-seek/references/fd-mdfind-recipes.md +0 -62
  127. package/sub-skills/local-seek/references/git-integration.md +0 -54
  128. package/sub-skills/local-seek/references/remote-handoff.md +0 -46
  129. package/sub-skills/local-seek/references/rg-recipes.md +0 -96
  130. package/sub-skills/local-seek/references/strategies.md +0 -52
  131. package/sub-skills/local-seek/references/structural-search.md +0 -59
  132. package/sub-skills/local-seek/tests/test_content_rank.py +0 -222
  133. package/sub-skills/local-seek/tests/test_determinism_20261006.py +0 -145
  134. package/sub-skills/local-seek/tests/test_noise_tier.py +0 -101
  135. package/sub-skills/local-seek/tests/test_pcre2_probe.py +0 -136
  136. package/sub-skills/local-seek/tests/test_rank_pinyin.py +0 -77
  137. package/sub-skills/local-seek/tests/test_regression_20261003.py +0 -169
  138. package/sub-skills/local-seek/tests/test_regression_20261006.py +0 -146
  139. package/sub-skills/local-seek/tests/test_seek_degradation.py +0 -111
  140. package/sub-skills/local-seek/tests/test_seek_fts.py +0 -313
@@ -1,169 +0,0 @@
1
- #!/usr/bin/env python3
2
- """2026-10-03 审计轮回归测试:四类假阴性 + run_query 契约 + timeout 下传。
3
-
4
- 守的四个实测缺陷(全部先在真机复现再修):
5
- 1. run_query 目录不存在分支 print + 裸 return 1,违反 (text, rc) 契约,
6
- MCP/include-local 进程内调用方解包直接 TypeError。
7
- 2. --since/--until 在 max 截断之后过滤:旧文件占满截断池,新命中被吃掉
8
- (实测 20 个新文件只报出 8 个)。
9
- 3. build_patterns 中文分支不拆英文多词:「性能 asyncio tutorial」的英文段
10
- 整段化作固定短语,单词条目全部漏掉(eb0ed38 只修了纯英文分支)。
11
- 4. --scope doc 把 md/txt 从 exts 减掉,文档场景恰好漏掉最常搜的笔记。
12
-
13
- 运行:
14
- cd ~/.agents/skills/argo
15
- python3 -m pytest sub-skills/local-seek/tests/test_regression_20261003.py -q
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- import io
21
- import json
22
- import os
23
- import sys
24
- import tempfile
25
- import unittest
26
- from contextlib import redirect_stdout
27
- from pathlib import Path
28
- from unittest import mock
29
-
30
- SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"
31
- sys.path.insert(0, str(SCRIPTS))
32
-
33
- import seek as s # noqa: E402
34
-
35
-
36
- def _run_main(argv):
37
- """跑 seek.main(),捕获 stdout,返回 (exit_code, stdout)。"""
38
- with mock.patch.object(sys, "argv", ["seek.py"] + argv):
39
- buf = io.StringIO()
40
- try:
41
- with redirect_stdout(buf):
42
- code = s.main()
43
- except SystemExit as e:
44
- code = e.code or 0
45
- return code or 0, buf.getvalue()
46
-
47
-
48
- class TestBuildPatterns(unittest.TestCase):
49
- """中英混排与纯英文分词(缺陷 3)。"""
50
-
51
- def test_mixed_cjk_english_splits_words(self):
52
- patterns, fixed = s.build_patterns("性能 asyncio tutorial")
53
- self.assertIn("asyncio", patterns, "英文段必须按词拆开")
54
- self.assertIn("tutorial", patterns)
55
- self.assertIn("性能", patterns)
56
- self.assertTrue(fixed)
57
-
58
- def test_pure_english_multiword_unchanged(self):
59
- patterns, _ = s.build_patterns("Python asyncio tutorial")
60
- self.assertEqual(patterns, ["Python", "asyncio", "tutorial"])
61
-
62
- def test_space_never_a_pattern(self):
63
- patterns, _ = s.build_patterns("樯橹 灰飞烟灭")
64
- self.assertNotIn("", patterns)
65
- self.assertNotIn(" ", patterns)
66
-
67
- def test_cjk_2gram_extension_kept(self):
68
- patterns, _ = s.build_patterns("数据抓取")
69
- self.assertIn("数据抓取", patterns)
70
- self.assertIn("抓取", patterns)
71
-
72
-
73
- class TestSinceFilterBeforeTruncation(unittest.TestCase):
74
- """--since 过滤必须发生在截断之前(缺陷 2,真实 rg 端到端)。"""
75
-
76
- def test_new_files_not_swallowed_by_old(self):
77
- if not s.tool_exists("rg"):
78
- self.skipTest("本机无 rg")
79
- with tempfile.TemporaryDirectory() as tmp:
80
- ancient = 900000000 # 1998 年,稳落任何时间窗之外
81
- for i in range(40):
82
- p = Path(tmp) / f"old_{i:02d}.txt"
83
- p.write_text("目标词命中 here\n")
84
- os.utime(p, (ancient, ancient))
85
- for i in range(20):
86
- (Path(tmp) / f"new_{i:02d}.txt").write_text("目标词命中 here\n")
87
- code, out = _run_main(
88
- ["目标词", "--path", tmp, "--since", "1d", "--max", "30", "--json"])
89
- self.assertEqual(code, 0)
90
- self.assertEqual(json.loads(out)["count"], 20,
91
- "20 个新文件全部在窗口内,一个都不能被旧文件挤掉")
92
-
93
- def test_no_window_returns_all(self):
94
- if not s.tool_exists("rg"):
95
- self.skipTest("本机无 rg")
96
- with tempfile.TemporaryDirectory() as tmp:
97
- for i in range(35):
98
- (Path(tmp) / f"f_{i:02d}.txt").write_text("目标词命中 here\n")
99
- code, out = _run_main(
100
- ["目标词", "--path", tmp, "--max", "30", "--json"])
101
- self.assertEqual(json.loads(out)["count"], 30)
102
-
103
-
104
- class TestScopeDocIncludesMd(unittest.TestCase):
105
- """--scope doc 必须搜到 md(缺陷 4)。"""
106
-
107
- def test_md_hit_in_doc_scope(self):
108
- if not s.tool_exists("rg"):
109
- self.skipTest("本机无 rg")
110
- with tempfile.TemporaryDirectory() as tmp:
111
- (Path(tmp) / "notes.md").write_text("检索词在这里\n")
112
- code, out = _run_main(
113
- ["检索词", "--path", tmp, "--scope", "doc", "--json"])
114
- self.assertEqual(code, 0, f"--scope doc 应命中 md,输出:{out}")
115
- data = json.loads(out)
116
- self.assertTrue(any("notes.md" in r["path"] for r in data["results"]))
117
-
118
-
119
- class TestRunQueryContract(unittest.TestCase):
120
- """run_query 必须始终返回 (text, rc)(缺陷 1)。"""
121
-
122
- def test_nonexistent_dir_returns_tuple(self):
123
- result = s.run_query(["x", "--path", "/nonexistent-seek-test-xyz"])
124
- self.assertIsInstance(result, tuple, "返回裸 int 会让进程内调用方解包崩溃")
125
- text, rc = result
126
- self.assertIsInstance(text, str)
127
- self.assertEqual(rc, 1)
128
- self.assertIn("目录不存在", text)
129
-
130
- def test_cli_still_prints_message(self):
131
- code, out = _run_main(["x", "--path", "/nonexistent-seek-test-xyz"])
132
- self.assertEqual(code, 1)
133
- self.assertIn("目录不存在", out)
134
-
135
-
136
- class TestTimeoutDownpass(unittest.TestCase):
137
- """structural/git 路径必须把 time_budget 下传给 run(缺陷 5)。"""
138
-
139
- def test_structural_passes_timeout(self):
140
- captured = {}
141
- def fake_run(cmd, timeout=None, **kw):
142
- captured["timeout"] = timeout
143
- proc = mock.Mock()
144
- proc.returncode = 1 # 无命中
145
- proc.stdout = ""
146
- return proc
147
- with mock.patch.object(s, "run", side_effect=fake_run):
148
- s.structural_search("裸except", "/tmp", [], 5, timeout=5.5)
149
- self.assertEqual(captured.get("timeout"), 5.5,
150
- "structural_search 必须把 timeout 传给 run")
151
-
152
- def test_git_log_passes_timeout(self):
153
- captured = {}
154
- def fake_run(cmd, timeout=None, **kw):
155
- captured["timeout"] = timeout
156
- proc = mock.Mock()
157
- proc.returncode = 1
158
- proc.stdout = ""
159
- return proc
160
- with tempfile.TemporaryDirectory() as tmp:
161
- f = Path(tmp) / "a.txt"
162
- f.write_text("x\n")
163
- with mock.patch.object(s, "run", side_effect=fake_run):
164
- s.git_log(str(f), timeout=7.5)
165
- self.assertEqual(captured.get("timeout"), 7.5)
166
-
167
-
168
- if __name__ == "__main__":
169
- unittest.main()
@@ -1,146 +0,0 @@
1
- #!/usr/bin/env python3
2
- """2026-10-06 性能轮回归测试:噪声档判定去 syscall + read_lines 单遍读取。
3
-
4
- 实测背景(cProfile,Documents/GPT 搜 "import" --count,8930 个文件):
5
- _is_noise_path 独占 1.52s / 1.91s(80%),内含 35727 次 posix.getcwd、
6
- 103266 次 posix.lstat、35720 次 realpath。根因是每行都做
7
- `Path(fp).resolve().relative_to(Path(root).resolve())`——resolve() 对每个
8
- 路径段 lstat 且反复 getcwd;而 _apply_noise_floor 还把根 resolve 了两遍、
9
- 每行判了两遍。
10
-
11
- 修法:纯字符串归一化(normpath + 前缀比较),根只算一次、每行只判一次,
12
- 全程零 syscall。本文件锁定两件事:
13
- 1. 语义不变——相对/绝对根、根自己叫 tests/、repos/、日期归档、越界路径
14
- 的判定结果与原实现一致;
15
- 2. 不得回退到 syscall——_is_noise_path 再调 Path.resolve / realpath / lstat
16
- 即报红(这是本轮性能收益的守门用例,比计时断言稳定)。
17
-
18
- read_lines 原实现先 open 数行、再 open 读区间(同一文件开两遍,大文件 IO
19
- 翻倍),改为单遍流式;本文件锁定区间/越界/空文件三种行为不变。
20
-
21
- 运行:
22
- cd ~/.agents/skills/argo
23
- python3 -m pytest sub-skills/local-seek/tests/test_regression_20261006.py -q
24
- """
25
-
26
- from __future__ import annotations
27
-
28
- import os
29
- import sys
30
- import tempfile
31
- import unittest
32
- from pathlib import Path
33
- from unittest import mock
34
-
35
- SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"
36
- sys.path.insert(0, str(SCRIPTS))
37
-
38
- import seek as s # noqa: E402
39
-
40
-
41
- class TestNoisePathSemantics(unittest.TestCase):
42
- """去 syscall 之后,噪声档判定语义必须与原实现逐条一致。"""
43
-
44
- def test_root_named_tests_is_not_noise(self):
45
- # 搜索根自己叫 tests/ 时整棵树不得被判噪声(原实现的核心理由)
46
- self.assertFalse(s._is_noise_path("/x/tests/a.py", "/x/tests"))
47
-
48
- def test_src_under_root_is_clean(self):
49
- self.assertFalse(s._is_noise_path("/x/src/app.py", "/x"))
50
-
51
- def test_repos_under_root_is_noise(self):
52
- self.assertTrue(s._is_noise_path("/x/repos/lib.py", "/x"))
53
-
54
- def test_date_archive_dir_is_noise(self):
55
- self.assertTrue(s._is_noise_path("/x/2026-09-26_吸纳/a.md", "/x"))
56
-
57
- def test_relative_root(self):
58
- # 默认根 "." —— rg 输出相对路径时的常见形态
59
- self.assertTrue(s._is_noise_path("repos/lib.py", "."))
60
- self.assertFalse(s._is_noise_path("src/app.py", "."))
61
-
62
- def test_out_of_root_falls_back_to_full_path(self):
63
- # 不在根之下(软链/越界):退回整条路径判定
64
- self.assertTrue(s._is_noise_path("/other/repos/lib.py", "/x"))
65
- self.assertFalse(s._is_noise_path("/other/src/lib.py", "/x"))
66
-
67
- def test_noise_floor_clean_first(self):
68
- rows = [("/x/repos/a.py", 0, ""), ("/x/src/b.py", 0, "")]
69
- out = s._apply_noise_floor(rows, "/x", 30)
70
- self.assertEqual(out[0][0], "/x/src/b.py",
71
- "真源必须排在噪声档之前")
72
-
73
- def test_noise_floor_backfills_when_clean_short(self):
74
- # 真源不足时噪声档补满(不是排除,保证仍可达)
75
- rows = [("/x/repos/a.py", 0, ""), ("/x/src/b.py", 0, "")]
76
- out = s._apply_noise_floor(rows, "/x", 2)
77
- self.assertEqual(len(out), 2)
78
-
79
-
80
- class TestNoisePathNoSyscall(unittest.TestCase):
81
- """性能守门:判定路径不得再触发任何文件系统解析调用。"""
82
-
83
- def test_does_not_call_path_resolve(self):
84
- with mock.patch.object(Path, "resolve",
85
- side_effect=AssertionError("又调 Path.resolve() 了")):
86
- rows = [(f"/root/repos/f{i}.py", 0, "") for i in range(100)]
87
- s._apply_noise_floor(rows, "/root", 30)
88
-
89
- def test_does_not_call_realpath_or_lstat(self):
90
- with mock.patch.object(os.path, "realpath",
91
- side_effect=AssertionError("又调 realpath() 了")), \
92
- mock.patch.object(os, "lstat",
93
- side_effect=AssertionError("又调 lstat() 了")):
94
- rows = [(f"/root/repos/f{i}.py", 0, "") for i in range(100)]
95
- s._apply_noise_floor(rows, "/root", 30)
96
-
97
-
98
- class TestReadLinesSinglePass(unittest.TestCase):
99
- """read_lines 单遍读取:区间/夹取/越界/空文件行为与原实现一致。"""
100
-
101
- def _write(self, tmp, text):
102
- f = Path(tmp) / "a.txt"
103
- f.write_text(text, encoding="utf-8")
104
- return str(f)
105
-
106
- def test_range_slice(self):
107
- with tempfile.TemporaryDirectory() as tmp:
108
- fp = self._write(tmp, "\n".join(f"L{i}" for i in range(1, 11)) + "\n")
109
- text, rc = s.read_lines(fp, "3-5")
110
- self.assertEqual(rc, 0)
111
- self.assertIn("3: L3", text)
112
- self.assertIn("5: L5", text)
113
- self.assertNotIn("6: L6", text)
114
- self.assertIn("共 10 行", text)
115
-
116
- def test_clamped_to_total(self):
117
- with tempfile.TemporaryDirectory() as tmp:
118
- fp = self._write(tmp, "L1\nL2\n")
119
- text, rc = s.read_lines(fp, "1-99")
120
- self.assertEqual(rc, 0)
121
- self.assertIn("第 1-2 行", text)
122
-
123
- def test_start_past_end(self):
124
- with tempfile.TemporaryDirectory() as tmp:
125
- fp = self._write(tmp, "L1\n")
126
- text, rc = s.read_lines(fp, "5-6")
127
- self.assertEqual(rc, 1)
128
- self.assertIn("只有 1 行", text)
129
-
130
- def test_empty_file(self):
131
- with tempfile.TemporaryDirectory() as tmp:
132
- fp = self._write(tmp, "")
133
- text, rc = s.read_lines(fp, "1-2")
134
- self.assertEqual(rc, 1)
135
- self.assertIn("只有 0 行", text)
136
-
137
- def test_bad_spec(self):
138
- with tempfile.TemporaryDirectory() as tmp:
139
- fp = self._write(tmp, "L1\n")
140
- text, rc = s.read_lines(fp, "abc")
141
- self.assertEqual(rc, 1)
142
- self.assertIn("N-M", text)
143
-
144
-
145
- if __name__ == "__main__":
146
- unittest.main()
@@ -1,111 +0,0 @@
1
- #!/usr/bin/env python3
2
- """local-seek 降级链与 --count 排序回归测试(P0-2 / P0-4,2026-09-13)。
3
-
4
- 守的两个缺陷:
5
- 1. 降级链曾把「工具缺失」伪装成「搜索结论」:scope=code 且缺 rg 时落
6
- mdfind(Spotlight 不索引源码内容)→ 搜 argo 自身符号返回 24 条外部
7
- 陈旧副本、漏掉正主,还报「未找到匹配(mdfind)」。修复后:缺 rg 直落
8
- grep,两者皆无才显式报错。
9
- 2. --count 曾按 rg 原始输出顺序(目录遍历序)截断,「哪些文件命中最多」
10
- 系统性答错。修复后:全量收集→按命中数降序→取前 N。
11
-
12
- 运行:
13
- cd ~/.agents/skills/argo
14
- python3 -m pytest sub-skills/local-seek/tests/test_seek_degradation.py -q
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- import io
20
- import json
21
- import sys
22
- import tempfile
23
- import unittest
24
- from contextlib import redirect_stdout
25
- from pathlib import Path
26
- from unittest import mock
27
-
28
- SCRIPTS = Path(__file__).resolve().parent.parent / "scripts"
29
- sys.path.insert(0, str(SCRIPTS))
30
-
31
- import seek as s # noqa: E402
32
-
33
-
34
- def _run_main(argv):
35
- """跑 seek.main(),捕获 stdout,返回 (exit_code, stdout)。"""
36
- with mock.patch.object(sys, "argv", ["seek.py"] + argv):
37
- buf = io.StringIO()
38
- try:
39
- with redirect_stdout(buf):
40
- code = s.main()
41
- except SystemExit as e:
42
- code = e.code or 0
43
- return code or 0, buf.getvalue()
44
-
45
-
46
- class TestDegradationChain(unittest.TestCase):
47
- """scope=code 缺 rg 时的降级(全部 mock,不依赖本机装了什么)。"""
48
-
49
- def _argv(self, tmp: str) -> list[str]:
50
- return ["def needle", "--scope", "code", "--path", tmp, "--json"]
51
-
52
- def test_rg_missing_falls_to_grep(self):
53
- with tempfile.TemporaryDirectory() as tmp:
54
- (Path(tmp) / "a.py").write_text("def needle(x):\n return x\n")
55
- with mock.patch.object(s, "tool_exists",
56
- lambda name: name in ("grep", "mdfind")), \
57
- mock.patch.object(s, "resolve_grep", lambda: "/usr/bin/grep"), \
58
- mock.patch.object(s, "grep_search",
59
- lambda *a, **k: ([(str(Path(tmp) / "a.py"), 1, "def needle(x)")], None)):
60
- code, out = _run_main(self._argv(tmp))
61
- self.assertEqual(code, 0)
62
- data = json.loads(out)
63
- self.assertEqual(data["engine"], "grep",
64
- "缺 rg 时必须落 grep,不能落 mdfind")
65
-
66
- def test_rg_and_grep_missing_reports_tools_not_nomatch(self):
67
- with tempfile.TemporaryDirectory() as tmp:
68
- with mock.patch.object(s, "tool_exists", lambda name: False), \
69
- mock.patch.object(s, "resolve_grep", lambda: None):
70
- code, out = _run_main(self._argv(tmp))
71
- self.assertEqual(code, 1)
72
- self.assertIn("rg", out)
73
- self.assertIn("grep", out)
74
- self.assertNotIn("未找到匹配", out,
75
- "工具缺失不得伪装成搜索结论")
76
-
77
- def test_real_rg_still_used(self):
78
- """rg 在位时行为不变(防把好路径改坏)。"""
79
- with tempfile.TemporaryDirectory() as tmp:
80
- (Path(tmp) / "a.py").write_text("def needle(x):\n return x\n")
81
- code, out = _run_main(self._argv(tmp))
82
- self.assertEqual(code, 0)
83
- self.assertEqual(json.loads(out)["engine"], "rg")
84
-
85
-
86
- class TestCountOrdering(unittest.TestCase):
87
- """--count 按命中数降序(真实 rg 端到端)。"""
88
-
89
- def test_most_hits_first(self):
90
- if not s.tool_exists("rg"):
91
- self.skipTest("本机无 rg")
92
- with tempfile.TemporaryDirectory() as tmp:
93
- # 遍历序(字典序)在前面的文件命中少、后面的命中多:
94
- # 旧实现按遍历序截断会答错。
95
- (Path(tmp) / "aaa.txt").write_text("needle\n") # 1 次
96
- (Path(tmp) / "zzz.txt").write_text("needle\nneedle\nneedle\n") # 3 次
97
- code, out = _run_main(
98
- ["needle", "--scope", "code", "--path", tmp, "--count",
99
- "--json"])
100
- self.assertEqual(code, 0)
101
- data = json.loads(out)
102
- results = data.get("results") or []
103
- files = [r.get("path") if isinstance(r, dict) else r[0]
104
- for r in results]
105
- self.assertTrue(files, "无结果")
106
- self.assertIn("zzz.txt", files[0],
107
- f"命中最多的文件必须排第一,实际 {files}")
108
-
109
-
110
- if __name__ == "__main__":
111
- unittest.main()