loopgen 0.1.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.
- package/LICENSE +21 -0
- package/README.md +414 -0
- package/dist/adapters/agents-md.js +77 -0
- package/dist/adapters/claude.js +88 -0
- package/dist/adapters/codex.js +97 -0
- package/dist/adapters/cursor.js +41 -0
- package/dist/adapters/local-model.js +172 -0
- package/dist/adapters/windsurf.js +41 -0
- package/dist/cli.js +211 -0
- package/dist/core/adapters.js +162 -0
- package/dist/core/diff.js +29 -0
- package/dist/core/fs-plan.js +12 -0
- package/dist/core/generator.js +226 -0
- package/dist/core/scanner.js +304 -0
- package/dist/core/templates.js +624 -0
- package/dist/core/types.js +1 -0
- package/dist/server.js +241 -0
- package/dist-web/assets/index-BrxUKxHo.css +1 -0
- package/dist-web/assets/index-CIWs8r78.js +13 -0
- package/dist-web/index.html +13 -0
- package/examples/demo-webapp/.github/workflows/ci.yml +20 -0
- package/examples/demo-webapp/README.md +13 -0
- package/examples/demo-webapp/package-lock.json +1246 -0
- package/examples/demo-webapp/package.json +15 -0
- package/examples/demo-webapp/src/checkout.test.ts +13 -0
- package/examples/demo-webapp/src/checkout.ts +9 -0
- package/package.json +67 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { stringify } from "yaml";
|
|
4
|
+
import { generateAgentsFiles } from "../adapters/agents-md.js";
|
|
5
|
+
import { generateClaudeFiles } from "../adapters/claude.js";
|
|
6
|
+
import { generateCodexFiles } from "../adapters/codex.js";
|
|
7
|
+
import { generateCursorFiles } from "../adapters/cursor.js";
|
|
8
|
+
import { generateLocalModelFiles, localModelWarnings } from "../adapters/local-model.js";
|
|
9
|
+
import { generateWindsurfFiles } from "../adapters/windsurf.js";
|
|
10
|
+
import { DEFAULT_ADAPTER_IDS, normalizeAdapterConfigs } from "./adapters.js";
|
|
11
|
+
import { createPreviewDiff } from "./diff.js";
|
|
12
|
+
import { defaultAnswers, createLoopSpec, TEMPLATE_DEFINITIONS, templateIds } from "./templates.js";
|
|
13
|
+
import { scanProject } from "./scanner.js";
|
|
14
|
+
export async function generateLoopProject(options) {
|
|
15
|
+
const experienceMode = options.experienceMode ?? "project";
|
|
16
|
+
const projectRoot = resolveProjectRoot(options, experienceMode);
|
|
17
|
+
const scan = await scanProject(projectRoot);
|
|
18
|
+
const adapters = options.adapters?.length ? unique(options.adapters) : DEFAULT_ADAPTER_IDS;
|
|
19
|
+
const adapterConfigs = normalizeAdapterConfigs(options.adapterConfigs);
|
|
20
|
+
const selectedTemplates = options.selectedTemplates?.length
|
|
21
|
+
? unique(options.selectedTemplates)
|
|
22
|
+
: defaultTemplateSelection(options, experienceMode);
|
|
23
|
+
const answers = defaultAnswers(scan, {
|
|
24
|
+
adapters,
|
|
25
|
+
adapterConfigs,
|
|
26
|
+
selectedTemplates,
|
|
27
|
+
triggerCadence: options.triggerCadence ?? "manual",
|
|
28
|
+
acceptanceCriteria: options.acceptanceCriteria,
|
|
29
|
+
allowPrCreation: options.allowPrCreation ?? false,
|
|
30
|
+
allowedCommands: options.allowedCommands?.length ? options.allowedCommands : undefined,
|
|
31
|
+
maxIterations: options.maxIterations ?? 3
|
|
32
|
+
});
|
|
33
|
+
const loops = answers.selectedTemplates.map((id) => createLoopSpec(id, scan, answers));
|
|
34
|
+
const files = sortFiles([
|
|
35
|
+
{
|
|
36
|
+
path: ".loopgen/loopgen.loop.yaml",
|
|
37
|
+
content: renderLoopYaml(scan.projectName, loops)
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
path: ".loopgen/README.md",
|
|
41
|
+
content: renderLoopgenReadme(scan.projectName)
|
|
42
|
+
},
|
|
43
|
+
...loops.map((loop) => ({
|
|
44
|
+
path: `.loopgen/playbooks/${loop.id}.md`,
|
|
45
|
+
content: renderPlaybook(scan.projectName, loop)
|
|
46
|
+
})),
|
|
47
|
+
...loops.map((loop) => ({
|
|
48
|
+
path: loop.stateFile,
|
|
49
|
+
content: renderStateFile(loop.id, loop.title)
|
|
50
|
+
})),
|
|
51
|
+
...(answers.adapters.includes("agents-md") ? generateAgentsFiles(scan, loops) : []),
|
|
52
|
+
...(answers.adapters.includes("codex") ? generateCodexFiles(scan, loops) : []),
|
|
53
|
+
...(answers.adapters.includes("claude") ? generateClaudeFiles(scan, loops) : []),
|
|
54
|
+
...(answers.adapters.includes("cursor") ? generateCursorFiles(scan, loops) : []),
|
|
55
|
+
...(answers.adapters.includes("windsurf") ? generateWindsurfFiles(scan, loops) : []),
|
|
56
|
+
...(answers.adapters.includes("ollama")
|
|
57
|
+
? generateLocalModelFiles(scan, loops, { adapterId: "ollama", config: answers.adapterConfigs.ollama ?? {} })
|
|
58
|
+
: []),
|
|
59
|
+
...(answers.adapters.includes("openai-compatible")
|
|
60
|
+
? generateLocalModelFiles(scan, loops, {
|
|
61
|
+
adapterId: "openai-compatible",
|
|
62
|
+
config: answers.adapterConfigs["openai-compatible"] ?? {}
|
|
63
|
+
})
|
|
64
|
+
: [])
|
|
65
|
+
]);
|
|
66
|
+
const warnings = [
|
|
67
|
+
...scan.warnings,
|
|
68
|
+
...answers.adapters.flatMap((adapter) => localModelWarnings(adapter, answers.adapterConfigs[adapter])),
|
|
69
|
+
...loops
|
|
70
|
+
.filter((loop) => loop.verification.requiresHumanCommandDefinition)
|
|
71
|
+
.map((loop) => `${loop.id} needs a real verification command before automated execution.`)
|
|
72
|
+
];
|
|
73
|
+
return {
|
|
74
|
+
experienceMode,
|
|
75
|
+
scan,
|
|
76
|
+
loops,
|
|
77
|
+
files,
|
|
78
|
+
diff: await createPreviewDiff(scan.root, files),
|
|
79
|
+
warnings
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export function demoProjectRoot() {
|
|
83
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
84
|
+
return path.resolve(currentDir, "..", "..", "examples", "demo-webapp");
|
|
85
|
+
}
|
|
86
|
+
function resolveProjectRoot(options, experienceMode) {
|
|
87
|
+
if (experienceMode === "demo") {
|
|
88
|
+
return demoProjectRoot();
|
|
89
|
+
}
|
|
90
|
+
return path.resolve(options.projectRoot ?? ".");
|
|
91
|
+
}
|
|
92
|
+
function defaultTemplateSelection(options, experienceMode) {
|
|
93
|
+
const templates = filterTemplateDefinitions(options);
|
|
94
|
+
if (experienceMode === "demo") {
|
|
95
|
+
const demoRecommended = templates.filter((template) => template.demoAvailable && template.recommendedForDemo);
|
|
96
|
+
if (demoRecommended.length > 0)
|
|
97
|
+
return demoRecommended.map((template) => template.id);
|
|
98
|
+
return templates.filter((template) => template.demoAvailable).map((template) => template.id);
|
|
99
|
+
}
|
|
100
|
+
return templates.length ? templates.map((template) => template.id) : templateIds();
|
|
101
|
+
}
|
|
102
|
+
function filterTemplateDefinitions(options) {
|
|
103
|
+
return TEMPLATE_DEFINITIONS.filter((template) => {
|
|
104
|
+
const audienceMatches = !options.audienceFilter || template.audience.includes(options.audienceFilter);
|
|
105
|
+
const categoryMatches = !options.categoryFilter || template.category === options.categoryFilter;
|
|
106
|
+
return audienceMatches && categoryMatches;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function renderLoopYaml(projectName, loops) {
|
|
110
|
+
return stringify({
|
|
111
|
+
version: "0.1",
|
|
112
|
+
project: projectName,
|
|
113
|
+
generatedBy: "loopgen",
|
|
114
|
+
generatedAt: new Date().toISOString(),
|
|
115
|
+
loops
|
|
116
|
+
}, { lineWidth: 110 });
|
|
117
|
+
}
|
|
118
|
+
function renderLoopgenReadme(projectName) {
|
|
119
|
+
return `# loopgen for ${projectName}
|
|
120
|
+
|
|
121
|
+
This directory contains local-first loop engineering configuration generated by loopgen.
|
|
122
|
+
|
|
123
|
+
Start with \`.loopgen/loopgen.loop.yaml\`, then read the matching \`.loopgen/playbooks/*.md\` file.
|
|
124
|
+
If you use Codex or Claude, use the generated skill or loop guide that matches the same loop id.
|
|
125
|
+
|
|
126
|
+
Safety defaults:
|
|
127
|
+
|
|
128
|
+
- Preview diffs before applying changes.
|
|
129
|
+
- Keep maker and checker work separate.
|
|
130
|
+
- Stop after the configured maximum iterations.
|
|
131
|
+
- Do not read production secrets or forbidden paths.
|
|
132
|
+
- Do not declare success without verification output.
|
|
133
|
+
`;
|
|
134
|
+
}
|
|
135
|
+
function renderPlaybook(projectName, loop) {
|
|
136
|
+
return `# ${loop.title} playbook
|
|
137
|
+
|
|
138
|
+
Project: ${projectName}
|
|
139
|
+
Loop id: ${loop.id}
|
|
140
|
+
Category: ${loop.category}
|
|
141
|
+
Audience: ${loop.audience.join(", ")}
|
|
142
|
+
Difficulty: ${loop.difficulty}
|
|
143
|
+
|
|
144
|
+
## Goal
|
|
145
|
+
|
|
146
|
+
${loop.goal}
|
|
147
|
+
|
|
148
|
+
Expected outcome: ${loop.expectedOutcome}
|
|
149
|
+
|
|
150
|
+
## When to run
|
|
151
|
+
|
|
152
|
+
- Trigger type: ${loop.trigger.type}
|
|
153
|
+
- Cadence: ${loop.trigger.cadence}
|
|
154
|
+
- Sources: ${loop.trigger.sources.join(", ")}
|
|
155
|
+
|
|
156
|
+
## Context to gather
|
|
157
|
+
|
|
158
|
+
${loop.contextSources.map((source) => `- ${source}`).join("\n")}
|
|
159
|
+
|
|
160
|
+
## Loop steps
|
|
161
|
+
|
|
162
|
+
${loop.actions.map((action, index) => `${index + 1}. ${action}`).join("\n")}
|
|
163
|
+
|
|
164
|
+
## Verification
|
|
165
|
+
|
|
166
|
+
Run these commands before declaring success:
|
|
167
|
+
|
|
168
|
+
${loop.verification.commands.map((command) => `- \`${command}\``).join("\n")}
|
|
169
|
+
|
|
170
|
+
Acceptance criteria: ${loop.verification.acceptanceCriteria}
|
|
171
|
+
|
|
172
|
+
## Safety and state
|
|
173
|
+
|
|
174
|
+
- State file: \`${loop.stateFile}\`
|
|
175
|
+
- Maximum iterations: ${loop.stopCriteria.maxIterations}
|
|
176
|
+
- Timeout minutes: ${loop.stopCriteria.timeoutMinutes}
|
|
177
|
+
- Maker/checker separation required: ${loop.verification.makerChecker ? "yes" : "no"}
|
|
178
|
+
- Network allowed: ${loop.permissions.allowNetwork ? "yes" : "no"}
|
|
179
|
+
- PR creation allowed: ${loop.permissions.allowPrCreation ? "yes" : "no"}
|
|
180
|
+
- Do not read or modify: ${loop.permissions.forbiddenPaths.map((item) => `\`${item}\``).join(", ")}
|
|
181
|
+
|
|
182
|
+
Stop and ask for human input when:
|
|
183
|
+
|
|
184
|
+
${loop.stopCriteria.requireHumanInputOn.map((condition) => `- ${condition}`).join("\n")}
|
|
185
|
+
${loop.verification.requiresHumanCommandDefinition ? "\nThis playbook is in draft mode until the TODO verification command is replaced.\n" : ""}
|
|
186
|
+
`;
|
|
187
|
+
}
|
|
188
|
+
function renderStateFile(id, title) {
|
|
189
|
+
return `# ${title} state
|
|
190
|
+
|
|
191
|
+
Loop id: ${id}
|
|
192
|
+
|
|
193
|
+
## Attempts
|
|
194
|
+
|
|
195
|
+
- No attempts yet.
|
|
196
|
+
`;
|
|
197
|
+
}
|
|
198
|
+
function sortFiles(files) {
|
|
199
|
+
return files.sort((a, b) => fileSortKey(a.path).localeCompare(fileSortKey(b.path)));
|
|
200
|
+
}
|
|
201
|
+
function fileSortKey(filePath) {
|
|
202
|
+
if (filePath.startsWith(".loopgen/playbooks/"))
|
|
203
|
+
return `0:${filePath}`;
|
|
204
|
+
if (filePath.startsWith(".loopgen/adapters/"))
|
|
205
|
+
return `1:${filePath}`;
|
|
206
|
+
if (filePath === ".loopgen/README.md")
|
|
207
|
+
return `2:${filePath}`;
|
|
208
|
+
if (filePath === ".loopgen/loopgen.loop.yaml")
|
|
209
|
+
return `3:${filePath}`;
|
|
210
|
+
if (filePath.startsWith(".loopgen/state/"))
|
|
211
|
+
return `4:${filePath}`;
|
|
212
|
+
if (filePath.startsWith(".codex/"))
|
|
213
|
+
return `5:${filePath}`;
|
|
214
|
+
if (filePath.startsWith(".claude/"))
|
|
215
|
+
return `6:${filePath}`;
|
|
216
|
+
if (filePath === "AGENTS.md")
|
|
217
|
+
return `7:${filePath}`;
|
|
218
|
+
if (filePath.startsWith(".cursor/"))
|
|
219
|
+
return `8:${filePath}`;
|
|
220
|
+
if (filePath === ".windsurfrules")
|
|
221
|
+
return `9:${filePath}`;
|
|
222
|
+
return `10:${filePath}`;
|
|
223
|
+
}
|
|
224
|
+
function unique(items) {
|
|
225
|
+
return [...new Set(items)];
|
|
226
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const IGNORED_DIRS = new Set([
|
|
4
|
+
".git",
|
|
5
|
+
"node_modules",
|
|
6
|
+
"dist",
|
|
7
|
+
"dist-web",
|
|
8
|
+
"build",
|
|
9
|
+
"coverage",
|
|
10
|
+
".cache",
|
|
11
|
+
".next",
|
|
12
|
+
"target",
|
|
13
|
+
"__pycache__"
|
|
14
|
+
]);
|
|
15
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
16
|
+
".ts",
|
|
17
|
+
".tsx",
|
|
18
|
+
".js",
|
|
19
|
+
".jsx",
|
|
20
|
+
".mjs",
|
|
21
|
+
".cjs",
|
|
22
|
+
".py",
|
|
23
|
+
".go",
|
|
24
|
+
".rs",
|
|
25
|
+
".java",
|
|
26
|
+
".kt",
|
|
27
|
+
".swift",
|
|
28
|
+
".rb",
|
|
29
|
+
".php",
|
|
30
|
+
".cs"
|
|
31
|
+
]);
|
|
32
|
+
const CONFIG_EXTENSIONS = new Set([".json", ".yaml", ".yml", ".toml", ".ini"]);
|
|
33
|
+
export async function scanProject(projectRoot) {
|
|
34
|
+
const root = path.resolve(projectRoot);
|
|
35
|
+
const warnings = [];
|
|
36
|
+
await assertDirectory(root);
|
|
37
|
+
const packageJson = await readJson(path.join(root, "package.json"));
|
|
38
|
+
const scripts = readPackageScripts(packageJson);
|
|
39
|
+
const packageManagers = await detectPackageManagers(root);
|
|
40
|
+
const languages = await detectLanguages(root);
|
|
41
|
+
const workflowFiles = await findWorkflowFiles(root);
|
|
42
|
+
const files = await countProjectFiles(root);
|
|
43
|
+
const contextSources = await detectContextSources(root, workflowFiles);
|
|
44
|
+
const commands = inferCommands(root, scripts, packageManagers);
|
|
45
|
+
if (!commands.test && !commands.lint && !commands.build) {
|
|
46
|
+
warnings.push("No verification command was inferred. Generated loops will stay in draft mode until one is configured.");
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
root,
|
|
50
|
+
projectName: packageJson?.name ? String(packageJson.name) : path.basename(root),
|
|
51
|
+
detectedAt: new Date().toISOString(),
|
|
52
|
+
languages,
|
|
53
|
+
primaryLanguage: languages[0] ?? "Unknown",
|
|
54
|
+
packageManagers,
|
|
55
|
+
commands,
|
|
56
|
+
scripts,
|
|
57
|
+
ci: {
|
|
58
|
+
providers: workflowFiles.length > 0 ? ["github-actions"] : [],
|
|
59
|
+
workflowFiles: workflowFiles.map((file) => path.relative(root, file))
|
|
60
|
+
},
|
|
61
|
+
files,
|
|
62
|
+
contextSources,
|
|
63
|
+
warnings
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
async function assertDirectory(root) {
|
|
67
|
+
const stat = await fs.stat(root).catch(() => undefined);
|
|
68
|
+
if (!stat?.isDirectory()) {
|
|
69
|
+
throw new Error(`Project path is not a directory: ${root}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async function readJson(filePath) {
|
|
73
|
+
try {
|
|
74
|
+
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function readPackageScripts(packageJson) {
|
|
81
|
+
if (!packageJson || typeof packageJson.scripts !== "object" || !packageJson.scripts) {
|
|
82
|
+
return {};
|
|
83
|
+
}
|
|
84
|
+
return Object.fromEntries(Object.entries(packageJson.scripts).filter(([, value]) => typeof value === "string"));
|
|
85
|
+
}
|
|
86
|
+
async function detectPackageManagers(root) {
|
|
87
|
+
const checks = [
|
|
88
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
89
|
+
["package-lock.json", "npm"],
|
|
90
|
+
["yarn.lock", "yarn"],
|
|
91
|
+
["bun.lockb", "bun"],
|
|
92
|
+
["bun.lock", "bun"],
|
|
93
|
+
["poetry.lock", "poetry"],
|
|
94
|
+
["uv.lock", "uv"],
|
|
95
|
+
["go.mod", "go"],
|
|
96
|
+
["Cargo.toml", "cargo"]
|
|
97
|
+
];
|
|
98
|
+
const found = [];
|
|
99
|
+
for (const [file, manager] of checks) {
|
|
100
|
+
if (await exists(path.join(root, file))) {
|
|
101
|
+
found.push(manager);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if ((await exists(path.join(root, "package.json"))) && !found.some((manager) => ["pnpm", "npm", "yarn", "bun"].includes(manager))) {
|
|
105
|
+
found.push("npm");
|
|
106
|
+
}
|
|
107
|
+
if ((await exists(path.join(root, "pyproject.toml"))) && !found.includes("poetry") && !found.includes("uv")) {
|
|
108
|
+
found.push("python");
|
|
109
|
+
}
|
|
110
|
+
if ((await exists(path.join(root, "requirements.txt"))) && !found.includes("python")) {
|
|
111
|
+
found.push("python");
|
|
112
|
+
}
|
|
113
|
+
return found;
|
|
114
|
+
}
|
|
115
|
+
async function detectLanguages(root) {
|
|
116
|
+
const counts = new Map();
|
|
117
|
+
await walk(root, async (file) => {
|
|
118
|
+
const ext = path.extname(file);
|
|
119
|
+
const language = languageForExtension(ext);
|
|
120
|
+
if (language) {
|
|
121
|
+
counts.set(language, (counts.get(language) ?? 0) + 1);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([language]) => language);
|
|
125
|
+
}
|
|
126
|
+
function languageForExtension(ext) {
|
|
127
|
+
const map = {
|
|
128
|
+
".ts": "TypeScript",
|
|
129
|
+
".tsx": "TypeScript",
|
|
130
|
+
".js": "JavaScript",
|
|
131
|
+
".jsx": "JavaScript",
|
|
132
|
+
".mjs": "JavaScript",
|
|
133
|
+
".cjs": "JavaScript",
|
|
134
|
+
".py": "Python",
|
|
135
|
+
".go": "Go",
|
|
136
|
+
".rs": "Rust",
|
|
137
|
+
".java": "Java",
|
|
138
|
+
".kt": "Kotlin",
|
|
139
|
+
".swift": "Swift",
|
|
140
|
+
".rb": "Ruby",
|
|
141
|
+
".php": "PHP",
|
|
142
|
+
".cs": "C#"
|
|
143
|
+
};
|
|
144
|
+
return map[ext];
|
|
145
|
+
}
|
|
146
|
+
async function findWorkflowFiles(root) {
|
|
147
|
+
const workflowsDir = path.join(root, ".github", "workflows");
|
|
148
|
+
const files = [];
|
|
149
|
+
if (await exists(workflowsDir)) {
|
|
150
|
+
await walk(workflowsDir, async (file) => {
|
|
151
|
+
if ([".yml", ".yaml"].includes(path.extname(file))) {
|
|
152
|
+
files.push(file);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (await exists(path.join(root, ".gitlab-ci.yml"))) {
|
|
157
|
+
files.push(path.join(root, ".gitlab-ci.yml"));
|
|
158
|
+
}
|
|
159
|
+
return files.sort();
|
|
160
|
+
}
|
|
161
|
+
async function countProjectFiles(root) {
|
|
162
|
+
const counts = {
|
|
163
|
+
total: 0,
|
|
164
|
+
source: 0,
|
|
165
|
+
tests: 0,
|
|
166
|
+
configs: 0
|
|
167
|
+
};
|
|
168
|
+
await walk(root, async (file) => {
|
|
169
|
+
counts.total += 1;
|
|
170
|
+
const ext = path.extname(file);
|
|
171
|
+
const normalized = path.relative(root, file).split(path.sep).join("/");
|
|
172
|
+
if (SOURCE_EXTENSIONS.has(ext)) {
|
|
173
|
+
counts.source += 1;
|
|
174
|
+
}
|
|
175
|
+
if (/(\btests?\b|\.test\.|\.spec\.|__tests__)/i.test(normalized)) {
|
|
176
|
+
counts.tests += 1;
|
|
177
|
+
}
|
|
178
|
+
if (CONFIG_EXTENSIONS.has(ext) || /(^|\/)(Makefile|Dockerfile|compose\.ya?ml)$/i.test(normalized)) {
|
|
179
|
+
counts.configs += 1;
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
return counts;
|
|
183
|
+
}
|
|
184
|
+
async function detectContextSources(root, workflowFiles) {
|
|
185
|
+
const candidates = [
|
|
186
|
+
"README.md",
|
|
187
|
+
"package.json",
|
|
188
|
+
"pyproject.toml",
|
|
189
|
+
"requirements.txt",
|
|
190
|
+
"go.mod",
|
|
191
|
+
"Cargo.toml",
|
|
192
|
+
"Makefile",
|
|
193
|
+
...workflowFiles.map((file) => path.relative(root, file))
|
|
194
|
+
];
|
|
195
|
+
const found = [];
|
|
196
|
+
for (const candidate of candidates) {
|
|
197
|
+
if (await exists(path.join(root, candidate))) {
|
|
198
|
+
found.push(candidate);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return found;
|
|
202
|
+
}
|
|
203
|
+
function inferCommands(root, scripts, managers) {
|
|
204
|
+
const commands = {};
|
|
205
|
+
const nodeManager = managers.find((manager) => ["pnpm", "npm", "yarn", "bun"].includes(manager));
|
|
206
|
+
if (nodeManager) {
|
|
207
|
+
commands.packageManager = nodeManager;
|
|
208
|
+
commands.install = installCommandFor(nodeManager);
|
|
209
|
+
if (scripts.test)
|
|
210
|
+
commands.test = runScriptCommand(nodeManager, "test");
|
|
211
|
+
if (scripts.lint)
|
|
212
|
+
commands.lint = runScriptCommand(nodeManager, "lint");
|
|
213
|
+
if (scripts.build)
|
|
214
|
+
commands.build = runScriptCommand(nodeManager, "build");
|
|
215
|
+
if (scripts.format)
|
|
216
|
+
commands.format = runScriptCommand(nodeManager, "format");
|
|
217
|
+
return commands;
|
|
218
|
+
}
|
|
219
|
+
if (managers.includes("go")) {
|
|
220
|
+
commands.packageManager = "go";
|
|
221
|
+
commands.test = "go test ./...";
|
|
222
|
+
commands.build = "go build ./...";
|
|
223
|
+
return commands;
|
|
224
|
+
}
|
|
225
|
+
if (managers.includes("cargo")) {
|
|
226
|
+
commands.packageManager = "cargo";
|
|
227
|
+
commands.test = "cargo test";
|
|
228
|
+
commands.build = "cargo build";
|
|
229
|
+
return commands;
|
|
230
|
+
}
|
|
231
|
+
if (managers.includes("poetry")) {
|
|
232
|
+
commands.packageManager = "poetry";
|
|
233
|
+
commands.install = "poetry install";
|
|
234
|
+
commands.test = "poetry run pytest";
|
|
235
|
+
return commands;
|
|
236
|
+
}
|
|
237
|
+
if (managers.includes("uv")) {
|
|
238
|
+
commands.packageManager = "uv";
|
|
239
|
+
commands.install = "uv sync";
|
|
240
|
+
commands.test = "uv run pytest";
|
|
241
|
+
return commands;
|
|
242
|
+
}
|
|
243
|
+
if (managers.includes("python")) {
|
|
244
|
+
commands.packageManager = "python";
|
|
245
|
+
commands.install = "python -m pip install -r requirements.txt";
|
|
246
|
+
commands.test = "python -m pytest";
|
|
247
|
+
}
|
|
248
|
+
if (commands.install && !fileLikelyExists(root, "requirements.txt")) {
|
|
249
|
+
delete commands.install;
|
|
250
|
+
}
|
|
251
|
+
return commands;
|
|
252
|
+
}
|
|
253
|
+
function installCommandFor(manager) {
|
|
254
|
+
if (manager === "pnpm")
|
|
255
|
+
return "pnpm install --frozen-lockfile";
|
|
256
|
+
if (manager === "yarn")
|
|
257
|
+
return "yarn install --frozen-lockfile";
|
|
258
|
+
if (manager === "bun")
|
|
259
|
+
return "bun install --frozen-lockfile";
|
|
260
|
+
return "npm ci";
|
|
261
|
+
}
|
|
262
|
+
function runScriptCommand(manager, script) {
|
|
263
|
+
if (manager === "npm")
|
|
264
|
+
return `npm run ${script}`;
|
|
265
|
+
if (manager === "yarn")
|
|
266
|
+
return `yarn ${script}`;
|
|
267
|
+
if (manager === "bun")
|
|
268
|
+
return `bun run ${script}`;
|
|
269
|
+
return `pnpm ${script}`;
|
|
270
|
+
}
|
|
271
|
+
function fileLikelyExists(root, file) {
|
|
272
|
+
return root.length > 0 && file.length > 0;
|
|
273
|
+
}
|
|
274
|
+
async function exists(filePath) {
|
|
275
|
+
try {
|
|
276
|
+
await fs.access(filePath);
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
catch {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
async function walk(root, onFile) {
|
|
284
|
+
const queue = [root];
|
|
285
|
+
while (queue.length > 0) {
|
|
286
|
+
const current = queue.shift();
|
|
287
|
+
const entries = await fs.readdir(current, { withFileTypes: true }).catch(() => []);
|
|
288
|
+
for (const entry of entries) {
|
|
289
|
+
if (entry.name.startsWith(".") && entry.name !== ".github" && entry.name !== ".gitlab-ci.yml") {
|
|
290
|
+
if (entry.name !== ".github")
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const fullPath = path.join(current, entry.name);
|
|
294
|
+
if (entry.isDirectory()) {
|
|
295
|
+
if (!IGNORED_DIRS.has(entry.name)) {
|
|
296
|
+
queue.push(fullPath);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
else if (entry.isFile()) {
|
|
300
|
+
await onFile(fullPath);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|