helloloop 0.1.1 → 0.2.0

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.
@@ -0,0 +1,155 @@
1
+ import path from "node:path";
2
+
3
+ import { expandDocumentEntries } from "./doc_loader.mjs";
4
+ import {
5
+ classifyExplicitPath,
6
+ findRepoRootFromPath,
7
+ looksLikeProjectRoot,
8
+ normalizeForRepo,
9
+ pathExists,
10
+ resolveAbsolute,
11
+ } from "./discovery_paths.mjs";
12
+ import {
13
+ inferDocsForRepo,
14
+ inferRepoFromDocs,
15
+ renderMissingDocsMessage,
16
+ renderMissingRepoMessage,
17
+ } from "./discovery_inference.mjs";
18
+
19
+ export { findRepoRootFromPath } from "./discovery_paths.mjs";
20
+
21
+ export function discoverWorkspace(options = {}) {
22
+ const cwd = path.resolve(options.cwd || process.cwd());
23
+ const configDirName = options.configDirName || ".helloloop";
24
+ const explicitRepoRoot = options.repoRoot ? resolveAbsolute(options.repoRoot, cwd) : "";
25
+ const explicitDocsPath = options.docsPath ? resolveAbsolute(options.docsPath, cwd) : "";
26
+ const explicitInputPath = options.inputPath ? resolveAbsolute(options.inputPath, cwd) : "";
27
+
28
+ if (explicitRepoRoot && !pathExists(explicitRepoRoot)) {
29
+ return { ok: false, code: "missing_repo_path", message: `项目路径不存在:${explicitRepoRoot}` };
30
+ }
31
+ if (explicitDocsPath && !pathExists(explicitDocsPath)) {
32
+ return { ok: false, code: "missing_docs_path", message: `开发文档路径不存在:${explicitDocsPath}` };
33
+ }
34
+
35
+ let repoRoot = explicitRepoRoot;
36
+ let docsEntries = explicitDocsPath ? [explicitDocsPath] : [];
37
+ let docCandidates = [];
38
+ let repoCandidates = [];
39
+
40
+ if (!repoRoot && !docsEntries.length) {
41
+ const classified = classifyExplicitPath(explicitInputPath);
42
+ if (classified.kind === "missing") {
43
+ return { ok: false, code: "missing_input_path", message: `路径不存在:${classified.absolutePath}` };
44
+ }
45
+ if (classified.kind === "docs") {
46
+ docsEntries = [classified.absolutePath];
47
+ }
48
+ if (classified.kind === "repo") {
49
+ repoRoot = classified.absolutePath;
50
+ }
51
+ }
52
+
53
+ if (!repoRoot && !docsEntries.length) {
54
+ const cwdRepoRoot = findRepoRootFromPath(cwd);
55
+ if (cwdRepoRoot) {
56
+ repoRoot = cwdRepoRoot;
57
+ } else if (classifyExplicitPath(cwd).kind === "docs") {
58
+ docsEntries = [cwd];
59
+ } else if (looksLikeProjectRoot(cwd)) {
60
+ repoRoot = cwd;
61
+ }
62
+ }
63
+
64
+ if (!repoRoot && docsEntries.length) {
65
+ const inferred = inferRepoFromDocs(docsEntries, cwd);
66
+ repoRoot = inferred.repoRoot;
67
+ repoCandidates = inferred.candidates;
68
+ }
69
+
70
+ if (!repoRoot) {
71
+ return {
72
+ ok: false,
73
+ code: "missing_repo",
74
+ message: renderMissingRepoMessage(docsEntries, repoCandidates),
75
+ };
76
+ }
77
+
78
+ repoRoot = findRepoRootFromPath(repoRoot) || repoRoot;
79
+ docsEntries = docsEntries.map((entry) => normalizeForRepo(repoRoot, entry));
80
+
81
+ if (!docsEntries.length) {
82
+ const inferred = inferDocsForRepo(repoRoot, cwd, configDirName);
83
+ docsEntries = inferred.docsEntries;
84
+ docCandidates = inferred.candidates;
85
+ }
86
+
87
+ if (!docsEntries.length) {
88
+ return {
89
+ ok: false,
90
+ code: "missing_docs",
91
+ repoRoot,
92
+ message: renderMissingDocsMessage(repoRoot),
93
+ candidates: docCandidates,
94
+ };
95
+ }
96
+
97
+ const resolvedDocs = expandDocumentEntries(repoRoot, docsEntries);
98
+ if (!resolvedDocs.length) {
99
+ return {
100
+ ok: false,
101
+ code: "invalid_docs",
102
+ repoRoot,
103
+ message: renderMissingDocsMessage(repoRoot),
104
+ };
105
+ }
106
+
107
+ return {
108
+ ok: true,
109
+ repoRoot,
110
+ docsEntries,
111
+ resolvedDocs,
112
+ };
113
+ }
114
+
115
+ export function resolveRepoRoot(options = {}) {
116
+ const cwd = path.resolve(options.cwd || process.cwd());
117
+ const explicitRepoRoot = options.repoRoot ? resolveAbsolute(options.repoRoot, cwd) : "";
118
+ const explicitInputPath = options.inputPath ? resolveAbsolute(options.inputPath, cwd) : "";
119
+
120
+ if (explicitRepoRoot) {
121
+ if (!pathExists(explicitRepoRoot)) {
122
+ return { ok: false, message: `项目路径不存在:${explicitRepoRoot}` };
123
+ }
124
+ return { ok: true, repoRoot: findRepoRootFromPath(explicitRepoRoot) || explicitRepoRoot };
125
+ }
126
+
127
+ const classified = classifyExplicitPath(explicitInputPath);
128
+ if (classified.kind === "missing") {
129
+ return { ok: false, message: `路径不存在:${classified.absolutePath}` };
130
+ }
131
+ if (classified.kind === "repo") {
132
+ return { ok: true, repoRoot: findRepoRootFromPath(classified.absolutePath) || classified.absolutePath };
133
+ }
134
+ if (classified.kind === "docs") {
135
+ const inferred = inferRepoFromDocs([classified.absolutePath], cwd);
136
+ if (inferred.repoRoot) {
137
+ return { ok: true, repoRoot: inferred.repoRoot };
138
+ }
139
+ return { ok: false, message: renderMissingRepoMessage([classified.absolutePath], inferred.candidates) };
140
+ }
141
+
142
+ const repoRoot = findRepoRootFromPath(cwd);
143
+ if (repoRoot) {
144
+ return { ok: true, repoRoot };
145
+ }
146
+
147
+ if (looksLikeProjectRoot(cwd)) {
148
+ return { ok: true, repoRoot: cwd };
149
+ }
150
+
151
+ return {
152
+ ok: false,
153
+ message: "当前目录不是项目仓库。请切到项目目录,或传入一个项目路径/开发文档路径。",
154
+ };
155
+ }
@@ -0,0 +1,220 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { readJson } from "./common.mjs";
5
+ import { expandDocumentEntries } from "./doc_loader.mjs";
6
+ import {
7
+ findRepoRootFromPath,
8
+ isDocFile,
9
+ isDocsDirectory,
10
+ looksLikeProjectRoot,
11
+ normalizeForRepo,
12
+ pathExists,
13
+ resolveAbsolute,
14
+ uniquePaths,
15
+ } from "./discovery_paths.mjs";
16
+
17
+ function loadExistingRequiredDocs(repoRoot, configDirName) {
18
+ const projectFile = path.join(repoRoot, configDirName, "project.json");
19
+ if (!pathExists(projectFile)) {
20
+ return [];
21
+ }
22
+
23
+ const projectConfig = readJson(projectFile);
24
+ const entries = Array.isArray(projectConfig.requiredDocs) ? projectConfig.requiredDocs : [];
25
+ const resolved = expandDocumentEntries(repoRoot, entries);
26
+ return resolved.length ? entries : [];
27
+ }
28
+
29
+ function readDocPreview(docEntries, cwd) {
30
+ let remaining = 24000;
31
+ const snippets = [];
32
+
33
+ for (const entry of docEntries) {
34
+ const expanded = expandDocumentEntries(cwd, [entry]);
35
+ for (const file of expanded) {
36
+ if (remaining <= 0) {
37
+ return snippets.join("\n");
38
+ }
39
+
40
+ const content = fs.readFileSync(file.absolutePath, "utf8");
41
+ const slice = content.slice(0, Math.min(content.length, remaining));
42
+ snippets.push(slice);
43
+ remaining -= slice.length;
44
+ }
45
+ }
46
+
47
+ return snippets.join("\n");
48
+ }
49
+
50
+ function normalizePathHint(rawValue) {
51
+ const trimmed = String(rawValue || "").trim().replace(/^["'`(<\[]+|[>"'`)\].,;:]+$/g, "");
52
+ if (/^\/[A-Za-z]:[\\/]/.test(trimmed)) {
53
+ return trimmed.slice(1);
54
+ }
55
+ return trimmed;
56
+ }
57
+
58
+ function extractPathHintsFromText(text) {
59
+ const hints = new Set();
60
+ const normalizedText = String(text || "");
61
+ const windowsMatches = normalizedText.match(/\/?[A-Za-z]:[\\/][^\s"'`<>()\]]+/g) || [];
62
+ const posixMatches = normalizedText.match(/(?:^|[\s("'`])\/[^\s"'`<>()\]]+/g) || [];
63
+
64
+ for (const rawMatch of [...windowsMatches, ...posixMatches]) {
65
+ const normalized = normalizePathHint(rawMatch);
66
+ if (normalized) {
67
+ hints.add(normalized);
68
+ }
69
+ }
70
+
71
+ return [...hints];
72
+ }
73
+
74
+ function extractRepoNameHintsFromText(text) {
75
+ const hints = new Set();
76
+ const matches = String(text || "").match(/`([A-Za-z0-9._-]{3,})`/g) || [];
77
+
78
+ for (const match of matches) {
79
+ const value = match.slice(1, -1);
80
+ if (/^(docs?|src|tests?|readme|sqlite|wal|sqlcipher)$/i.test(value)) {
81
+ continue;
82
+ }
83
+ hints.add(value.toLowerCase());
84
+ }
85
+
86
+ return [...hints];
87
+ }
88
+
89
+ function listNearbyProjectCandidates(searchRoot) {
90
+ if (!pathExists(searchRoot) || !fs.statSync(searchRoot).isDirectory()) {
91
+ return [];
92
+ }
93
+
94
+ return fs.readdirSync(searchRoot, { withFileTypes: true })
95
+ .filter((entry) => entry.isDirectory())
96
+ .map((entry) => path.join(searchRoot, entry.name))
97
+ .filter((directoryPath) => looksLikeProjectRoot(directoryPath));
98
+ }
99
+
100
+ export function inferRepoFromDocs(docEntries, cwd) {
101
+ const expandedFiles = expandDocumentEntries(cwd, docEntries);
102
+ const absoluteEntries = expandedFiles.map((item) => item.absolutePath);
103
+
104
+ for (const absolutePath of absoluteEntries) {
105
+ const repoRoot = findRepoRootFromPath(absolutePath);
106
+ if (repoRoot) {
107
+ return {
108
+ repoRoot,
109
+ candidates: [repoRoot],
110
+ source: "ancestor",
111
+ };
112
+ }
113
+ }
114
+
115
+ const previewText = readDocPreview(docEntries, cwd);
116
+ const pathHintCandidates = extractPathHintsFromText(previewText)
117
+ .map((item) => resolveAbsolute(item, cwd))
118
+ .filter((candidate) => pathExists(candidate))
119
+ .map((candidate) => (fs.statSync(candidate).isDirectory() ? candidate : path.dirname(candidate)))
120
+ .map((candidate) => findRepoRootFromPath(candidate) || (looksLikeProjectRoot(candidate) ? candidate : ""))
121
+ .filter(Boolean);
122
+
123
+ const uniquePathCandidates = uniquePaths(pathHintCandidates);
124
+ if (uniquePathCandidates.length === 1) {
125
+ return {
126
+ repoRoot: uniquePathCandidates[0],
127
+ candidates: uniquePathCandidates,
128
+ source: "doc_path_hint",
129
+ };
130
+ }
131
+
132
+ const repoNameHints = extractRepoNameHintsFromText(previewText);
133
+ const searchRoots = uniquePaths(
134
+ absoluteEntries.flatMap((absolutePath) => {
135
+ const directory = fs.statSync(absolutePath).isDirectory()
136
+ ? absolutePath
137
+ : path.dirname(absolutePath);
138
+ const parent = path.dirname(directory);
139
+ return [directory, parent, path.dirname(parent)];
140
+ }),
141
+ );
142
+ const nearbyCandidates = uniquePaths(
143
+ searchRoots.flatMap((searchRoot) => listNearbyProjectCandidates(searchRoot)),
144
+ );
145
+ const namedCandidates = nearbyCandidates.filter((directoryPath) => (
146
+ repoNameHints.includes(path.basename(directoryPath).toLowerCase())
147
+ ));
148
+
149
+ if (namedCandidates.length === 1) {
150
+ return {
151
+ repoRoot: namedCandidates[0],
152
+ candidates: namedCandidates,
153
+ source: "doc_repo_name_hint",
154
+ };
155
+ }
156
+
157
+ return {
158
+ repoRoot: "",
159
+ candidates: uniquePaths([...uniquePathCandidates, ...namedCandidates]),
160
+ source: "",
161
+ };
162
+ }
163
+
164
+ export function inferDocsForRepo(repoRoot, cwd, configDirName) {
165
+ const existingRequiredDocs = loadExistingRequiredDocs(repoRoot, configDirName);
166
+ if (existingRequiredDocs.length) {
167
+ return {
168
+ docsEntries: existingRequiredDocs,
169
+ source: "existing_state",
170
+ candidates: existingRequiredDocs,
171
+ };
172
+ }
173
+
174
+ const cwdIsDocSource = pathExists(cwd) && (isDocFile(cwd) || isDocsDirectory(cwd));
175
+ if (cwdIsDocSource) {
176
+ return {
177
+ docsEntries: [normalizeForRepo(repoRoot, cwd)],
178
+ source: "cwd",
179
+ candidates: [cwd],
180
+ };
181
+ }
182
+
183
+ const repoDocsCandidates = uniquePaths([
184
+ path.join(repoRoot, "docs"),
185
+ path.join(repoRoot, "Docs"),
186
+ ].filter((candidate) => isDocsDirectory(candidate)));
187
+
188
+ if (repoDocsCandidates.length === 1) {
189
+ return {
190
+ docsEntries: [normalizeForRepo(repoRoot, repoDocsCandidates[0])],
191
+ source: "repo_docs",
192
+ candidates: repoDocsCandidates,
193
+ };
194
+ }
195
+
196
+ return {
197
+ docsEntries: [],
198
+ source: "",
199
+ candidates: repoDocsCandidates,
200
+ };
201
+ }
202
+
203
+ export function renderMissingRepoMessage(docEntries, candidates) {
204
+ return [
205
+ "无法自动确定要开发的项目仓库路径。",
206
+ docEntries.length ? `已找到开发文档:${docEntries.join(", ")}` : "",
207
+ candidates.length > 1 ? `本地发现多个候选项目:${candidates.map((item) => path.basename(item)).join(", ")}` : "",
208
+ "请补充项目仓库路径后重试,例如:",
209
+ "npx helloloop --repo <PROJECT_ROOT>",
210
+ ].filter(Boolean).join("\n");
211
+ }
212
+
213
+ export function renderMissingDocsMessage(repoRoot) {
214
+ return [
215
+ "无法自动确定开发文档位置。",
216
+ `已找到项目仓库:${repoRoot}`,
217
+ "请补充开发文档路径后重试,例如:",
218
+ "npx helloloop --docs <DOCS_PATH>",
219
+ ].join("\n");
220
+ }
@@ -0,0 +1,166 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { fileExists } from "./common.mjs";
5
+
6
+ const DOC_FILE_SUFFIX = new Set([
7
+ ".md",
8
+ ".markdown",
9
+ ".mdx",
10
+ ".txt",
11
+ ".rst",
12
+ ".adoc",
13
+ ]);
14
+
15
+ const DOC_DIR_NAMES = new Set([
16
+ "docs",
17
+ "doc",
18
+ "documentation",
19
+ ]);
20
+
21
+ const PROJECT_MARKERS = [
22
+ ".git",
23
+ "package.json",
24
+ "pyproject.toml",
25
+ "Cargo.toml",
26
+ "go.mod",
27
+ "pom.xml",
28
+ "composer.json",
29
+ ];
30
+
31
+ const PROJECT_DIR_MARKERS = [
32
+ "src",
33
+ "app",
34
+ "apps",
35
+ "packages",
36
+ "tests",
37
+ ];
38
+
39
+ function directoryContainsDocs(directoryPath) {
40
+ if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
41
+ return false;
42
+ }
43
+
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
+ ));
48
+ }
49
+
50
+ function walkUpDirectories(startPath) {
51
+ const directories = [];
52
+ let current = fs.statSync(startPath).isDirectory()
53
+ ? startPath
54
+ : path.dirname(startPath);
55
+
56
+ while (true) {
57
+ directories.push(current);
58
+ const parent = path.dirname(current);
59
+ if (parent === current) {
60
+ break;
61
+ }
62
+ current = parent;
63
+ }
64
+
65
+ return directories;
66
+ }
67
+
68
+ export function resolveAbsolute(rawPath, cwd) {
69
+ return path.isAbsolute(rawPath)
70
+ ? path.resolve(rawPath)
71
+ : path.resolve(cwd, rawPath);
72
+ }
73
+
74
+ export function pathExists(targetPath) {
75
+ return fileExists(targetPath);
76
+ }
77
+
78
+ export function isDocFile(targetPath) {
79
+ return pathExists(targetPath)
80
+ && fs.statSync(targetPath).isFile()
81
+ && DOC_FILE_SUFFIX.has(path.extname(targetPath).toLowerCase());
82
+ }
83
+
84
+ export function looksLikeProjectRoot(directoryPath) {
85
+ if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
86
+ return false;
87
+ }
88
+
89
+ if (PROJECT_MARKERS.some((name) => pathExists(path.join(directoryPath, name)))) {
90
+ return true;
91
+ }
92
+
93
+ return PROJECT_DIR_MARKERS.some((name) => pathExists(path.join(directoryPath, name)));
94
+ }
95
+
96
+ export function isDocsDirectory(directoryPath) {
97
+ if (!pathExists(directoryPath) || !fs.statSync(directoryPath).isDirectory()) {
98
+ return false;
99
+ }
100
+
101
+ const baseName = path.basename(directoryPath).toLowerCase();
102
+ if (DOC_DIR_NAMES.has(baseName)) {
103
+ return true;
104
+ }
105
+
106
+ return directoryContainsDocs(directoryPath) && !looksLikeProjectRoot(directoryPath);
107
+ }
108
+
109
+ export function findRepoRootFromPath(startPath) {
110
+ if (!pathExists(startPath)) {
111
+ return "";
112
+ }
113
+
114
+ for (const directory of walkUpDirectories(startPath)) {
115
+ if (pathExists(path.join(directory, ".git"))) {
116
+ return directory;
117
+ }
118
+ }
119
+
120
+ if (fs.statSync(startPath).isDirectory() && looksLikeProjectRoot(startPath)) {
121
+ return startPath;
122
+ }
123
+
124
+ return "";
125
+ }
126
+
127
+ export function normalizeForRepo(repoRoot, targetPath) {
128
+ const relative = path.relative(repoRoot, targetPath);
129
+ if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) {
130
+ return relative.replaceAll("\\", "/");
131
+ }
132
+
133
+ return targetPath.replaceAll("\\", "/");
134
+ }
135
+
136
+ export function uniquePaths(items) {
137
+ const seen = new Set();
138
+ return items.filter((item) => {
139
+ const key = path.resolve(item).toLowerCase();
140
+ if (seen.has(key)) {
141
+ return false;
142
+ }
143
+ seen.add(key);
144
+ return true;
145
+ });
146
+ }
147
+
148
+ export function classifyExplicitPath(inputPath) {
149
+ if (!inputPath) {
150
+ return { kind: "", absolutePath: "" };
151
+ }
152
+
153
+ if (!pathExists(inputPath)) {
154
+ return { kind: "missing", absolutePath: inputPath };
155
+ }
156
+
157
+ if (isDocFile(inputPath) || isDocsDirectory(inputPath)) {
158
+ return { kind: "docs", absolutePath: inputPath };
159
+ }
160
+
161
+ if (fs.statSync(inputPath).isDirectory()) {
162
+ return { kind: "repo", absolutePath: inputPath };
163
+ }
164
+
165
+ return { kind: "unsupported", absolutePath: inputPath };
166
+ }
@@ -0,0 +1,59 @@
1
+ function normalizeRule(rule) {
2
+ return String(rule || "").trim();
3
+ }
4
+
5
+ function uniqueRules(items) {
6
+ const result = [];
7
+ const seen = new Set();
8
+
9
+ for (const item of items || []) {
10
+ const normalized = normalizeRule(item);
11
+ if (!normalized) {
12
+ continue;
13
+ }
14
+
15
+ const key = normalized.toLowerCase();
16
+ if (seen.has(key)) {
17
+ continue;
18
+ }
19
+
20
+ seen.add(key);
21
+ result.push(normalized);
22
+ }
23
+
24
+ return result;
25
+ }
26
+
27
+ const mandatoryGuardrails = [
28
+ "所有 shell 操作优先使用结构化命令与参数,避免字符串拼接命令。",
29
+ "涉及路径的 shell 操作必须正确引用路径,避免空格、中文、特殊字符造成路径逃逸。",
30
+ "涉及多路径或多子命令时必须拆成多次独立执行,禁止把多个路径操作拼接进单条命令。",
31
+ "Windows 环境禁止使用 cmd /c、cmd.exe、Start-Process cmd 或任何 cmd 嵌套命令;只允许 pwsh、bash(如 Git Bash)或 powershell 这类安全 shell。",
32
+ "涉及删除、移动、覆盖等危险文件操作前,必须先确认目标路径位于当前仓库或用户明确允许的目录内。",
33
+ "禁止执行 EHRB 高风险命令或其等价危险操作,例如 rm -rf /、git reset --hard、DROP DATABASE、FLUSHALL 等。",
34
+ "不得把密钥、令牌、.env、PII、真实绝对隐私路径写入代码、日志、文档和最终输出。",
35
+ "目标实现必须兼容 Windows、macOS、Linux,禁止硬编码平台专属路径分隔符或 shell 语法。",
36
+ ];
37
+
38
+ const defaultProjectConstraints = [
39
+ "代码是事实源;文档与代码冲突时,以当前代码、测试和真实目录结构为准。",
40
+ "产出必须达到专业级水准:架构清晰、实现完整、表达专业,不接受“能用就行”的交付。",
41
+ "所有文件修改必须使用当前环境提供的安全编辑方式,例如 apply_patch,而不是危险的原地批量命令。",
42
+ "不得通过压缩代码、删除必要空行、缩短命名来规避体积控制;单文件超过 400 行后按职责拆分。",
43
+ "不添加不必要的抽象层;仅为复杂逻辑添加必要注释,新增公共函数补简洁说明。",
44
+ "完成前必须主动运行验证;失败后先分析根因,再修复并重跑。",
45
+ "不允许静默失败、静默降级、静默回退;遇到阻塞必须明确说明原因。",
46
+ ];
47
+
48
+ export function listMandatoryGuardrails() {
49
+ return [...mandatoryGuardrails];
50
+ }
51
+
52
+ export function hasCustomProjectConstraints(items = []) {
53
+ return uniqueRules(items).length > 0;
54
+ }
55
+
56
+ export function resolveProjectConstraints(items = []) {
57
+ const customRules = uniqueRules(items);
58
+ return customRules.length ? customRules : [...defaultProjectConstraints];
59
+ }