helloloop 0.2.0 → 0.3.1

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/src/discovery.mjs CHANGED
@@ -8,25 +8,130 @@ import {
8
8
  normalizeForRepo,
9
9
  pathExists,
10
10
  resolveAbsolute,
11
+ uniquePaths,
11
12
  } from "./discovery_paths.mjs";
12
13
  import {
13
14
  inferDocsForRepo,
14
15
  inferRepoFromDocs,
16
+ inspectWorkspaceDirectory,
15
17
  renderMissingDocsMessage,
16
18
  renderMissingRepoMessage,
17
19
  } from "./discovery_inference.mjs";
18
20
 
19
21
  export { findRepoRootFromPath } from "./discovery_paths.mjs";
20
22
 
23
+ const CONFIDENCE_LABELS = {
24
+ high: "高",
25
+ medium: "中",
26
+ low: "低",
27
+ };
28
+
29
+ const REPO_SOURCE_META = {
30
+ explicit_flag: { label: "命令参数", confidence: "high" },
31
+ explicit_input: { label: "命令附带路径", confidence: "high" },
32
+ new_repo_input: { label: "命令附带路径", confidence: "high" },
33
+ interactive: { label: "交互确认", confidence: "high" },
34
+ interactive_new_repo: { label: "交互确认", confidence: "high" },
35
+ cwd_repo: { label: "当前目录", confidence: "medium" },
36
+ workspace_single_repo: { label: "工作区唯一候选项目", confidence: "medium" },
37
+ docs_ancestor: { label: "文档同树回溯", confidence: "medium" },
38
+ doc_path_hint: { label: "文档中的路径线索", confidence: "medium" },
39
+ doc_repo_name_hint: { label: "文档中的仓库名线索", confidence: "low" },
40
+ };
41
+
42
+ const DOCS_SOURCE_META = {
43
+ explicit_flag: { label: "命令参数", confidence: "high" },
44
+ explicit_input: { label: "命令附带路径", confidence: "high" },
45
+ interactive: { label: "交互确认", confidence: "high" },
46
+ cwd_docs: { label: "当前目录", confidence: "medium" },
47
+ workspace_single_doc: { label: "工作区唯一文档候选", confidence: "medium" },
48
+ existing_state: { label: "已有 .helloloop 配置", confidence: "medium" },
49
+ repo_docs: { label: "仓库 docs 目录", confidence: "medium" },
50
+ };
51
+
52
+ function pushBasis(basis, message) {
53
+ if (message && !basis.includes(message)) {
54
+ basis.push(message);
55
+ }
56
+ }
57
+
58
+ function repoSourceFromSelection(selectionSource, allowNewRepoRoot, repoRoot) {
59
+ if (selectionSource === "flag") {
60
+ return allowNewRepoRoot && repoRoot && !pathExists(repoRoot) ? "new_repo_input" : "explicit_flag";
61
+ }
62
+ if (selectionSource === "positional") {
63
+ return allowNewRepoRoot && repoRoot && !pathExists(repoRoot) ? "new_repo_input" : "explicit_input";
64
+ }
65
+ if (selectionSource === "interactive_new_repo") {
66
+ return "interactive_new_repo";
67
+ }
68
+ if (selectionSource === "interactive") {
69
+ return "interactive";
70
+ }
71
+ if (selectionSource === "workspace_single_repo") {
72
+ return "workspace_single_repo";
73
+ }
74
+ return "";
75
+ }
76
+
77
+ function docsSourceFromSelection(selectionSource) {
78
+ if (selectionSource === "flag") {
79
+ return "explicit_flag";
80
+ }
81
+ if (selectionSource === "positional") {
82
+ return "explicit_input";
83
+ }
84
+ if (selectionSource === "interactive") {
85
+ return "interactive";
86
+ }
87
+ if (selectionSource === "workspace_single_doc") {
88
+ return "workspace_single_doc";
89
+ }
90
+ return "";
91
+ }
92
+
93
+ function createResolution(kind, payload) {
94
+ const meta = kind === "repo" ? REPO_SOURCE_META : DOCS_SOURCE_META;
95
+ const selected = meta[payload.source] || { label: "自动判断", confidence: "medium" };
96
+ return {
97
+ ...payload,
98
+ sourceLabel: selected.label,
99
+ confidence: selected.confidence,
100
+ confidenceLabel: CONFIDENCE_LABELS[selected.confidence] || "中",
101
+ basis: Array.isArray(payload.basis) ? payload.basis.filter(Boolean) : [],
102
+ };
103
+ }
104
+
105
+ function createRepoResolution(repoRoot, source, basis) {
106
+ return createResolution("repo", {
107
+ source,
108
+ path: repoRoot,
109
+ exists: pathExists(repoRoot),
110
+ basis,
111
+ });
112
+ }
113
+
114
+ function createDocsResolution(docsEntries, source, basis) {
115
+ return createResolution("docs", {
116
+ source,
117
+ entries: docsEntries,
118
+ basis,
119
+ });
120
+ }
121
+
21
122
  export function discoverWorkspace(options = {}) {
22
123
  const cwd = path.resolve(options.cwd || process.cwd());
23
124
  const configDirName = options.configDirName || ".helloloop";
125
+ const allowNewRepoRoot = Boolean(options.allowNewRepoRoot);
126
+ const selectionSources = options.selectionSources || {};
24
127
  const explicitRepoRoot = options.repoRoot ? resolveAbsolute(options.repoRoot, cwd) : "";
25
128
  const explicitDocsPath = options.docsPath ? resolveAbsolute(options.docsPath, cwd) : "";
26
129
  const explicitInputPath = options.inputPath ? resolveAbsolute(options.inputPath, cwd) : "";
27
130
 
28
131
  if (explicitRepoRoot && !pathExists(explicitRepoRoot)) {
29
- return { ok: false, code: "missing_repo_path", message: `项目路径不存在:${explicitRepoRoot}` };
132
+ if (!allowNewRepoRoot) {
133
+ return { ok: false, code: "missing_repo_path", message: `项目路径不存在:${explicitRepoRoot}` };
134
+ }
30
135
  }
31
136
  if (explicitDocsPath && !pathExists(explicitDocsPath)) {
32
137
  return { ok: false, code: "missing_docs_path", message: `开发文档路径不存在:${explicitDocsPath}` };
@@ -36,6 +141,39 @@ export function discoverWorkspace(options = {}) {
36
141
  let docsEntries = explicitDocsPath ? [explicitDocsPath] : [];
37
142
  let docCandidates = [];
38
143
  let repoCandidates = [];
144
+ let workspaceRoot = "";
145
+ let docsDerivedFromWorkspace = false;
146
+ let repoSource = repoSourceFromSelection(selectionSources.repo, allowNewRepoRoot, explicitRepoRoot);
147
+ let docsSource = docsSourceFromSelection(selectionSources.docs);
148
+ const repoBasis = [];
149
+ const docsBasis = [];
150
+
151
+ if (explicitRepoRoot && !repoSource) {
152
+ repoSource = allowNewRepoRoot && !pathExists(explicitRepoRoot) ? "new_repo_input" : "explicit_input";
153
+ }
154
+ if (explicitDocsPath && !docsSource) {
155
+ docsSource = "explicit_input";
156
+ }
157
+
158
+ if (repoSource === "explicit_flag") {
159
+ pushBasis(repoBasis, "目标项目来自命令参数 `--repo`。");
160
+ } else if (repoSource === "explicit_input") {
161
+ pushBasis(repoBasis, "目标项目来自命令中显式提供的路径。");
162
+ } else if (repoSource === "new_repo_input") {
163
+ pushBasis(repoBasis, "目标项目来自显式提供的项目路径;该目录当前不存在,将按新项目创建。");
164
+ } else if (repoSource === "interactive_new_repo") {
165
+ pushBasis(repoBasis, "目标项目由用户在确认流程中指定;该目录当前不存在,将按新项目创建。");
166
+ } else if (repoSource === "interactive") {
167
+ pushBasis(repoBasis, "目标项目由用户在确认流程中手动指定。");
168
+ }
169
+
170
+ if (docsSource === "explicit_flag") {
171
+ pushBasis(docsBasis, "开发文档来自命令参数 `--docs`。");
172
+ } else if (docsSource === "explicit_input") {
173
+ pushBasis(docsBasis, "开发文档来自命令中显式提供的路径。");
174
+ } else if (docsSource === "interactive") {
175
+ pushBasis(docsBasis, "开发文档由用户在确认流程中手动指定。");
176
+ }
39
177
 
40
178
  if (!repoRoot && !docsEntries.length) {
41
179
  const classified = classifyExplicitPath(explicitInputPath);
@@ -44,9 +182,16 @@ export function discoverWorkspace(options = {}) {
44
182
  }
45
183
  if (classified.kind === "docs") {
46
184
  docsEntries = [classified.absolutePath];
185
+ docsSource = "explicit_input";
186
+ pushBasis(docsBasis, "开发文档来自命令中传入的单一路径。");
47
187
  }
48
188
  if (classified.kind === "repo") {
49
189
  repoRoot = classified.absolutePath;
190
+ repoSource = "explicit_input";
191
+ pushBasis(repoBasis, "目标项目来自命令中传入的单一路径。");
192
+ }
193
+ if (classified.kind === "workspace" || classified.kind === "directory") {
194
+ workspaceRoot = classified.absolutePath;
50
195
  }
51
196
  }
52
197
 
@@ -54,17 +199,75 @@ export function discoverWorkspace(options = {}) {
54
199
  const cwdRepoRoot = findRepoRootFromPath(cwd);
55
200
  if (cwdRepoRoot) {
56
201
  repoRoot = cwdRepoRoot;
57
- } else if (classifyExplicitPath(cwd).kind === "docs") {
58
- docsEntries = [cwd];
59
- } else if (looksLikeProjectRoot(cwd)) {
60
- repoRoot = cwd;
202
+ repoSource = "cwd_repo";
203
+ pushBasis(repoBasis, "当前终端目录已经位于一个项目仓库内。");
204
+ } else {
205
+ const cwdClassified = classifyExplicitPath(cwd);
206
+ if (cwdClassified.kind === "docs") {
207
+ docsEntries = [cwdClassified.absolutePath];
208
+ docsSource = "cwd_docs";
209
+ pushBasis(docsBasis, "当前终端目录本身就是开发文档目录或文件。");
210
+ } else if (cwdClassified.kind === "repo") {
211
+ repoRoot = cwdClassified.absolutePath;
212
+ repoSource = "cwd_repo";
213
+ pushBasis(repoBasis, "当前终端目录本身就是项目仓库。");
214
+ } else if (cwdClassified.kind === "workspace" || cwdClassified.kind === "directory") {
215
+ workspaceRoot = cwdClassified.absolutePath;
216
+ } else if (looksLikeProjectRoot(cwd)) {
217
+ repoRoot = cwd;
218
+ repoSource = "cwd_repo";
219
+ pushBasis(repoBasis, "当前终端目录具备项目仓库特征。");
220
+ }
221
+ }
222
+ }
223
+
224
+ if (!repoRoot && !docsEntries.length && workspaceRoot) {
225
+ const workspace = inspectWorkspaceDirectory(workspaceRoot);
226
+ docCandidates = workspace.docCandidates;
227
+ repoCandidates = workspace.repoCandidates;
228
+
229
+ if (workspace.docsEntries.length === 1) {
230
+ docsEntries = workspace.docsEntries;
231
+ docsDerivedFromWorkspace = true;
232
+ docsSource = "workspace_single_doc";
233
+ pushBasis(docsBasis, "工作区扫描后只发现一个顶层开发文档入口。");
234
+ }
235
+
236
+ if (workspace.repoCandidates.length === 1) {
237
+ repoRoot = workspace.repoCandidates[0];
238
+ repoSource = "workspace_single_repo";
239
+ pushBasis(repoBasis, "工作区扫描后只发现一个顶层项目候选目录。");
61
240
  }
62
241
  }
63
242
 
64
243
  if (!repoRoot && docsEntries.length) {
244
+ if (docsDerivedFromWorkspace && repoCandidates.length > 1) {
245
+ return {
246
+ ok: false,
247
+ code: "missing_repo",
248
+ message: renderMissingRepoMessage(docsEntries, repoCandidates),
249
+ docsEntries,
250
+ docCandidates,
251
+ repoCandidates,
252
+ workspaceRoot,
253
+ };
254
+ }
255
+
65
256
  const inferred = inferRepoFromDocs(docsEntries, cwd);
66
257
  repoRoot = inferred.repoRoot;
67
- repoCandidates = inferred.candidates;
258
+ repoCandidates = uniquePaths([...repoCandidates, ...inferred.candidates]);
259
+ if (repoRoot) {
260
+ if (inferred.source === "ancestor") {
261
+ repoSource = "docs_ancestor";
262
+ pushBasis(repoBasis, "开发文档位于该仓库目录树内,已回溯到真实项目根目录。");
263
+ } else if (inferred.source === "doc_path_hint") {
264
+ repoSource = "doc_path_hint";
265
+ pushBasis(repoBasis, "开发文档内容中出现了指向该仓库的实际路径线索。");
266
+ } else if (inferred.source === "doc_repo_name_hint") {
267
+ repoSource = "doc_repo_name_hint";
268
+ pushBasis(repoBasis, "开发文档内容中出现了与该仓库名称一致的线索。");
269
+ }
270
+ }
68
271
  }
69
272
 
70
273
  if (!repoRoot) {
@@ -72,16 +275,35 @@ export function discoverWorkspace(options = {}) {
72
275
  ok: false,
73
276
  code: "missing_repo",
74
277
  message: renderMissingRepoMessage(docsEntries, repoCandidates),
278
+ docsEntries,
279
+ docCandidates,
280
+ repoCandidates,
281
+ workspaceRoot,
75
282
  };
76
283
  }
77
284
 
78
- repoRoot = findRepoRootFromPath(repoRoot) || repoRoot;
285
+ const normalizedRepoRoot = findRepoRootFromPath(repoRoot) || repoRoot;
286
+ if (normalizedRepoRoot !== repoRoot) {
287
+ pushBasis(repoBasis, "已自动回溯到项目仓库根目录。");
288
+ }
289
+ repoRoot = normalizedRepoRoot;
79
290
  docsEntries = docsEntries.map((entry) => normalizeForRepo(repoRoot, entry));
80
291
 
81
292
  if (!docsEntries.length) {
82
293
  const inferred = inferDocsForRepo(repoRoot, cwd, configDirName);
83
294
  docsEntries = inferred.docsEntries;
84
295
  docCandidates = inferred.candidates;
296
+ if (docsEntries.length) {
297
+ docsSource = inferred.source || docsSource;
298
+ if (inferred.source === "existing_state") {
299
+ pushBasis(docsBasis, "已复用 `.helloloop/project.json` 中记录的 requiredDocs。");
300
+ } else if (inferred.source === "cwd") {
301
+ docsSource = "cwd_docs";
302
+ pushBasis(docsBasis, "当前终端目录本身就是开发文档目录或文件。");
303
+ } else if (inferred.source === "repo_docs") {
304
+ pushBasis(docsBasis, "已使用目标仓库中的 `docs/` 目录作为默认开发文档入口。");
305
+ }
306
+ }
85
307
  }
86
308
 
87
309
  if (!docsEntries.length) {
@@ -89,8 +311,12 @@ export function discoverWorkspace(options = {}) {
89
311
  ok: false,
90
312
  code: "missing_docs",
91
313
  repoRoot,
92
- message: renderMissingDocsMessage(repoRoot),
314
+ message: renderMissingDocsMessage(repoRoot, docCandidates),
93
315
  candidates: docCandidates,
316
+ docsEntries,
317
+ docCandidates,
318
+ repoCandidates,
319
+ workspaceRoot,
94
320
  };
95
321
  }
96
322
 
@@ -100,7 +326,11 @@ export function discoverWorkspace(options = {}) {
100
326
  ok: false,
101
327
  code: "invalid_docs",
102
328
  repoRoot,
103
- message: renderMissingDocsMessage(repoRoot),
329
+ message: renderMissingDocsMessage(repoRoot, docCandidates),
330
+ docsEntries,
331
+ docCandidates,
332
+ repoCandidates,
333
+ workspaceRoot,
104
334
  };
105
335
  }
106
336
 
@@ -109,6 +339,10 @@ export function discoverWorkspace(options = {}) {
109
339
  repoRoot,
110
340
  docsEntries,
111
341
  resolvedDocs,
342
+ resolution: {
343
+ repo: createRepoResolution(repoRoot, repoSource || "cwd_repo", repoBasis),
344
+ docs: createDocsResolution(docsEntries, docsSource || "repo_docs", docsBasis),
345
+ },
112
346
  };
113
347
  }
114
348
 
@@ -4,10 +4,12 @@ import path from "node:path";
4
4
  import { readJson } from "./common.mjs";
5
5
  import { expandDocumentEntries } from "./doc_loader.mjs";
6
6
  import {
7
+ findPreferredRepoRootFromPath,
7
8
  findRepoRootFromPath,
8
9
  isDocFile,
9
10
  isDocsDirectory,
10
- looksLikeProjectRoot,
11
+ listDocFilesInDirectory,
12
+ listProjectCandidatesInDirectory,
11
13
  normalizeForRepo,
12
14
  pathExists,
13
15
  resolveAbsolute,
@@ -48,7 +50,7 @@ function readDocPreview(docEntries, cwd) {
48
50
  }
49
51
 
50
52
  function normalizePathHint(rawValue) {
51
- const trimmed = String(rawValue || "").trim().replace(/^["'`(<\[]+|[>"'`)\].,;:]+$/g, "");
53
+ const trimmed = String(rawValue || "").trim().replace(/^[:"'`(<\[]+|[>"'`)\].,;::]+$/g, "");
52
54
  if (/^\/[A-Za-z]:[\\/]/.test(trimmed)) {
53
55
  return trimmed.slice(1);
54
56
  }
@@ -59,7 +61,7 @@ function extractPathHintsFromText(text) {
59
61
  const hints = new Set();
60
62
  const normalizedText = String(text || "");
61
63
  const windowsMatches = normalizedText.match(/\/?[A-Za-z]:[\\/][^\s"'`<>()\]]+/g) || [];
62
- const posixMatches = normalizedText.match(/(?:^|[\s("'`])\/[^\s"'`<>()\]]+/g) || [];
64
+ const posixMatches = normalizedText.match(/(?:^|[\s("'`::])\/(?!\/)[^\s"'`<>()\]]+/g) || [];
63
65
 
64
66
  for (const rawMatch of [...windowsMatches, ...posixMatches]) {
65
67
  const normalized = normalizePathHint(rawMatch);
@@ -86,15 +88,52 @@ function extractRepoNameHintsFromText(text) {
86
88
  return [...hints];
87
89
  }
88
90
 
89
- function listNearbyProjectCandidates(searchRoot) {
90
- if (!pathExists(searchRoot) || !fs.statSync(searchRoot).isDirectory()) {
91
+ function formatCandidates(title, candidates) {
92
+ if (!Array.isArray(candidates) || !candidates.length) {
91
93
  return [];
92
94
  }
93
95
 
94
- return fs.readdirSync(searchRoot, { withFileTypes: true })
96
+ return [
97
+ title,
98
+ ...candidates.map((item, index) => `${index + 1}. ${path.basename(item)} — ${item.replaceAll("\\", "/")}`),
99
+ ];
100
+ }
101
+
102
+ export function inspectWorkspaceDirectory(directoryPath) {
103
+ if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
104
+ return {
105
+ docCandidates: [],
106
+ docsEntries: [],
107
+ repoCandidates: [],
108
+ topLevelDirectories: [],
109
+ topLevelDocFiles: [],
110
+ };
111
+ }
112
+
113
+ const topLevelEntries = fs.readdirSync(directoryPath, { withFileTypes: true });
114
+ const namedDocsDirectories = topLevelEntries
115
+ .filter((entry) => (
116
+ entry.isDirectory()
117
+ && ["doc", "docs", "documentation"].includes(entry.name.toLowerCase())
118
+ ))
119
+ .map((entry) => path.join(directoryPath, entry.name))
120
+ .filter((candidate) => isDocsDirectory(candidate));
121
+
122
+ const topLevelDocFiles = listDocFilesInDirectory(directoryPath)
123
+ .filter((candidate) => path.basename(candidate).toLowerCase() !== "agents.md");
124
+ const docCandidates = uniquePaths([...topLevelDocFiles, ...namedDocsDirectories]);
125
+ const topLevelDirectories = topLevelEntries
95
126
  .filter((entry) => entry.isDirectory())
96
- .map((entry) => path.join(searchRoot, entry.name))
97
- .filter((directoryPath) => looksLikeProjectRoot(directoryPath));
127
+ .map((entry) => path.join(directoryPath, entry.name));
128
+
129
+ const repoCandidates = uniquePaths(listProjectCandidatesInDirectory(directoryPath));
130
+ return {
131
+ docCandidates,
132
+ docsEntries: docCandidates.length === 1 ? [docCandidates[0]] : [],
133
+ repoCandidates,
134
+ topLevelDirectories,
135
+ topLevelDocFiles,
136
+ };
98
137
  }
99
138
 
100
139
  export function inferRepoFromDocs(docEntries, cwd) {
@@ -117,7 +156,7 @@ export function inferRepoFromDocs(docEntries, cwd) {
117
156
  .map((item) => resolveAbsolute(item, cwd))
118
157
  .filter((candidate) => pathExists(candidate))
119
158
  .map((candidate) => (fs.statSync(candidate).isDirectory() ? candidate : path.dirname(candidate)))
120
- .map((candidate) => findRepoRootFromPath(candidate) || (looksLikeProjectRoot(candidate) ? candidate : ""))
159
+ .map((candidate) => findPreferredRepoRootFromPath(candidate))
121
160
  .filter(Boolean);
122
161
 
123
162
  const uniquePathCandidates = uniquePaths(pathHintCandidates);
@@ -136,11 +175,15 @@ export function inferRepoFromDocs(docEntries, cwd) {
136
175
  ? absolutePath
137
176
  : path.dirname(absolutePath);
138
177
  const parent = path.dirname(directory);
139
- return [directory, parent, path.dirname(parent)];
178
+ const roots = [directory, parent];
179
+ if (["doc", "docs", "documentation"].includes(path.basename(directory).toLowerCase())) {
180
+ roots.push(path.dirname(parent));
181
+ }
182
+ return roots;
140
183
  }),
141
184
  );
142
185
  const nearbyCandidates = uniquePaths(
143
- searchRoots.flatMap((searchRoot) => listNearbyProjectCandidates(searchRoot)),
186
+ searchRoots.flatMap((searchRoot) => listProjectCandidatesInDirectory(searchRoot)),
144
187
  );
145
188
  const namedCandidates = nearbyCandidates.filter((directoryPath) => (
146
189
  repoNameHints.includes(path.basename(directoryPath).toLowerCase())
@@ -156,7 +199,7 @@ export function inferRepoFromDocs(docEntries, cwd) {
156
199
 
157
200
  return {
158
201
  repoRoot: "",
159
- candidates: uniquePaths([...uniquePathCandidates, ...namedCandidates]),
202
+ candidates: uniquePaths([...uniquePathCandidates, ...namedCandidates, ...nearbyCandidates]),
160
203
  source: "",
161
204
  };
162
205
  }
@@ -203,18 +246,19 @@ export function inferDocsForRepo(repoRoot, cwd, configDirName) {
203
246
  export function renderMissingRepoMessage(docEntries, candidates) {
204
247
  return [
205
248
  "无法自动确定要开发的项目仓库路径。",
206
- docEntries.length ? `已找到开发文档:${docEntries.join(", ")}` : "",
207
- candidates.length > 1 ? `本地发现多个候选项目:${candidates.map((item) => path.basename(item)).join(", ")}` : "",
208
- "请补充项目仓库路径后重试,例如:",
249
+ docEntries.length ? `已找到开发文档:${docEntries.map((item) => item.replaceAll("\\", "/")).join(",")}` : "",
250
+ ...formatCandidates("候选项目:", candidates),
251
+ "可重新运行 `npx helloloop` 后按提示选择,或显式补充项目路径,例如:",
209
252
  "npx helloloop --repo <PROJECT_ROOT>",
210
253
  ].filter(Boolean).join("\n");
211
254
  }
212
255
 
213
- export function renderMissingDocsMessage(repoRoot) {
256
+ export function renderMissingDocsMessage(repoRoot, candidates = []) {
214
257
  return [
215
258
  "无法自动确定开发文档位置。",
216
- `已找到项目仓库:${repoRoot}`,
217
- "请补充开发文档路径后重试,例如:",
259
+ `已找到项目仓库:${repoRoot.replaceAll("\\", "/")}`,
260
+ ...formatCandidates("候选开发文档:", candidates),
261
+ "可重新运行 `npx helloloop` 后按提示选择,或显式补充开发文档路径,例如:",
218
262
  "npx helloloop --docs <DOCS_PATH>",
219
263
  ].join("\n");
220
264
  }
@@ -36,15 +36,80 @@ const PROJECT_DIR_MARKERS = [
36
36
  "tests",
37
37
  ];
38
38
 
39
- function directoryContainsDocs(directoryPath) {
39
+ const IGNORED_PROJECT_SEGMENTS = new Set([
40
+ ".cache",
41
+ ".git",
42
+ ".next",
43
+ ".nuxt",
44
+ ".pnpm",
45
+ ".turbo",
46
+ ".venv",
47
+ ".yarn",
48
+ "__pycache__",
49
+ "build",
50
+ "cache",
51
+ "coverage",
52
+ "dist",
53
+ "node_modules",
54
+ "out",
55
+ "target",
56
+ "temp",
57
+ "tmp",
58
+ "vendor",
59
+ "venv",
60
+ ]);
61
+
62
+ function listImmediateDirectories(directoryPath) {
40
63
  if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
41
- return false;
64
+ return [];
42
65
  }
43
66
 
44
- const entries = fs.readdirSync(directoryPath, { withFileTypes: true });
45
- return entries.some((entry) => (
46
- entry.isFile() && DOC_FILE_SUFFIX.has(path.extname(entry.name).toLowerCase())
47
- ));
67
+ return fs.readdirSync(directoryPath, { withFileTypes: true })
68
+ .filter((entry) => entry.isDirectory())
69
+ .map((entry) => path.join(directoryPath, entry.name));
70
+ }
71
+
72
+ function hasProjectMarker(directoryPath) {
73
+ return PROJECT_MARKERS.some((name) => pathExists(path.join(directoryPath, name)));
74
+ }
75
+
76
+ function looksLikeStrongProjectRoot(directoryPath) {
77
+ return pathExists(directoryPath)
78
+ && fs.statSync(directoryPath).isDirectory()
79
+ && hasProjectMarker(directoryPath);
80
+ }
81
+
82
+ export function listDocFilesInDirectory(directoryPath) {
83
+ if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
84
+ return [];
85
+ }
86
+
87
+ return fs.readdirSync(directoryPath, { withFileTypes: true })
88
+ .filter((entry) => (
89
+ entry.isFile() && DOC_FILE_SUFFIX.has(path.extname(entry.name).toLowerCase())
90
+ ))
91
+ .map((entry) => path.join(directoryPath, entry.name));
92
+ }
93
+
94
+ function directoryContainsDocs(directoryPath) {
95
+ return listDocFilesInDirectory(directoryPath).length > 0;
96
+ }
97
+
98
+ function hasIgnoredProjectBasename(targetPath) {
99
+ return IGNORED_PROJECT_SEGMENTS.has(path.basename(targetPath).toLowerCase());
100
+ }
101
+
102
+ function choosePreferredCandidate(candidates, directory) {
103
+ return candidates.find((candidate) => {
104
+ const relativeToLeaf = path.relative(candidate, directory);
105
+ return relativeToLeaf
106
+ .split(/[\\/]+/)
107
+ .filter(Boolean)
108
+ .some((segment) => IGNORED_PROJECT_SEGMENTS.has(segment.toLowerCase()));
109
+ })
110
+ || candidates.find((candidate) => !hasIgnoredProjectBasename(candidate))
111
+ || candidates[0]
112
+ || "";
48
113
  }
49
114
 
50
115
  function walkUpDirectories(startPath) {
@@ -86,13 +151,37 @@ export function looksLikeProjectRoot(directoryPath) {
86
151
  return false;
87
152
  }
88
153
 
89
- if (PROJECT_MARKERS.some((name) => pathExists(path.join(directoryPath, name)))) {
154
+ if (hasProjectMarker(directoryPath)) {
90
155
  return true;
91
156
  }
92
157
 
93
158
  return PROJECT_DIR_MARKERS.some((name) => pathExists(path.join(directoryPath, name)));
94
159
  }
95
160
 
161
+ export function listProjectCandidatesInDirectory(searchRoot) {
162
+ return listImmediateDirectories(searchRoot)
163
+ .filter((directoryPath) => (
164
+ looksLikeProjectRoot(directoryPath) && !hasIgnoredProjectBasename(directoryPath)
165
+ ));
166
+ }
167
+
168
+ export function looksLikeWorkspaceDirectory(directoryPath) {
169
+ if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
170
+ return false;
171
+ }
172
+
173
+ const repoCandidates = listProjectCandidatesInDirectory(directoryPath);
174
+ if (!repoCandidates.length) {
175
+ return false;
176
+ }
177
+
178
+ const hasLooseDocs = directoryContainsDocs(directoryPath)
179
+ || listImmediateDirectories(directoryPath)
180
+ .some((childPath) => DOC_DIR_NAMES.has(path.basename(childPath).toLowerCase()));
181
+
182
+ return repoCandidates.length > 1 || hasLooseDocs;
183
+ }
184
+
96
185
  export function isDocsDirectory(directoryPath) {
97
186
  if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
98
187
  return false;
@@ -103,6 +192,10 @@ export function isDocsDirectory(directoryPath) {
103
192
  return true;
104
193
  }
105
194
 
195
+ if (looksLikeWorkspaceDirectory(directoryPath)) {
196
+ return false;
197
+ }
198
+
106
199
  return directoryContainsDocs(directoryPath) && !looksLikeProjectRoot(directoryPath);
107
200
  }
108
201
 
@@ -124,6 +217,39 @@ export function findRepoRootFromPath(startPath) {
124
217
  return "";
125
218
  }
126
219
 
220
+ export function findPreferredRepoRootFromPath(startPath) {
221
+ if (!pathExists(startPath)) {
222
+ return "";
223
+ }
224
+
225
+ const directory = fs.statSync(startPath).isDirectory()
226
+ ? startPath
227
+ : path.dirname(startPath);
228
+ const strongCandidates = [];
229
+ const weakCandidates = [];
230
+
231
+ for (const current of walkUpDirectories(directory)) {
232
+ if (pathExists(path.join(current, ".git"))) {
233
+ return current;
234
+ }
235
+
236
+ if (looksLikeStrongProjectRoot(current)) {
237
+ strongCandidates.push(current);
238
+ continue;
239
+ }
240
+
241
+ if (looksLikeProjectRoot(current)) {
242
+ weakCandidates.push(current);
243
+ }
244
+ }
245
+
246
+ if (strongCandidates.length) {
247
+ return choosePreferredCandidate(strongCandidates, directory);
248
+ }
249
+
250
+ return choosePreferredCandidate(weakCandidates, directory);
251
+ }
252
+
127
253
  export function normalizeForRepo(repoRoot, targetPath) {
128
254
  const relative = path.relative(repoRoot, targetPath);
129
255
  if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
@@ -159,7 +285,16 @@ export function classifyExplicitPath(inputPath) {
159
285
  }
160
286
 
161
287
  if (fs.statSync(inputPath).isDirectory()) {
162
- return { kind: "repo", absolutePath: inputPath };
288
+ const repoRoot = findRepoRootFromPath(inputPath);
289
+ if (repoRoot) {
290
+ return { kind: "repo", absolutePath: repoRoot };
291
+ }
292
+
293
+ if (looksLikeWorkspaceDirectory(inputPath)) {
294
+ return { kind: "workspace", absolutePath: inputPath };
295
+ }
296
+
297
+ return { kind: "directory", absolutePath: inputPath };
163
298
  }
164
299
 
165
300
  return { kind: "unsupported", absolutePath: inputPath };