agent-rules-init 0.3.0 → 0.4.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 +21 -0
- package/dist/cli.d.ts +23 -4
- package/dist/cli.js +147 -38
- package/dist/core/canonical-commands.d.ts +4 -0
- package/dist/core/canonical-commands.js +169 -0
- package/dist/core/config.d.ts +25 -0
- package/dist/core/config.js +125 -0
- package/dist/core/i18n.d.ts +8 -5
- package/dist/core/i18n.js +61 -30
- package/dist/core/llm-bridge.d.ts +3 -0
- package/dist/core/llm-bridge.js +52 -0
- package/dist/core/project-unit-output.d.ts +13 -0
- package/dist/core/project-unit-output.js +25 -0
- package/dist/core/project-units.d.ts +16 -0
- package/dist/core/project-units.js +95 -0
- package/dist/core/repo-facts.d.ts +12 -2
- package/dist/core/repo-facts.js +233 -11
- package/dist/core/scanner.js +128 -25
- package/dist/core/templates.js +99 -26
- package/dist/core/types.d.ts +54 -3
- package/dist/core/writer.js +6 -6
- package/dist/packs/cpp.js +3 -3
- package/dist/packs/csharp.js +3 -3
- package/dist/packs/dart.js +4 -4
- package/dist/packs/elixir.js +2 -2
- package/dist/packs/go.js +2 -2
- package/dist/packs/java.js +57 -11
- package/dist/packs/js-ts.js +126 -23
- package/dist/packs/kotlin.js +29 -9
- package/dist/packs/php.js +4 -4
- package/dist/packs/python.js +81 -16
- package/dist/packs/r.js +4 -4
- package/dist/packs/ruby.js +6 -6
- package/dist/packs/rust.js +2 -2
- package/dist/packs/scala.js +3 -3
- package/dist/packs/swift.js +2 -2
- package/package.json +5 -3
package/dist/core/repo-facts.js
CHANGED
|
@@ -1,21 +1,55 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { parse } from "yaml";
|
|
3
3
|
import { UI } from "./i18n.js";
|
|
4
|
+
import { selectCanonicalCommands } from "./canonical-commands.js";
|
|
4
5
|
const NPM_DIRECT_LIFECYCLE = new Set(["test", "start", "stop", "restart"]);
|
|
5
|
-
export function
|
|
6
|
-
const scripts = signals.packageJson?.scripts ?? {};
|
|
6
|
+
export function extractJsPackageCommands(signals) {
|
|
7
7
|
const entries = [];
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
const locatedManifests = signals.packageJsons ?? [];
|
|
9
|
+
const hasLocatedManifests = locatedManifests.length > 0;
|
|
10
|
+
const manifests = hasLocatedManifests
|
|
11
|
+
? locatedManifests
|
|
12
|
+
: signals.packageJson
|
|
13
|
+
? [{ ...signals.packageJson, path: "package.json" }]
|
|
14
|
+
: [];
|
|
15
|
+
for (const manifest of manifests) {
|
|
16
|
+
const packageDir = path.posix.dirname(manifest.path);
|
|
17
|
+
const manager = manifest.packageManager ?? signals.packageJson?.packageManager ?? "npm";
|
|
18
|
+
for (const [name, body] of Object.entries(manifest.scripts)) {
|
|
19
|
+
if (typeof body !== "string" || body.trim() === "")
|
|
20
|
+
continue;
|
|
21
|
+
entries.push({
|
|
22
|
+
source: manager,
|
|
23
|
+
invocation: jsScriptInvocation(manager, packageDir, name),
|
|
24
|
+
detail: body.trim(),
|
|
25
|
+
...(hasLocatedManifests ? { manifestPath: manifest.path } : {}),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
16
28
|
}
|
|
17
29
|
return entries;
|
|
18
30
|
}
|
|
31
|
+
/** @deprecated Conservado como alias de API; también devuelve comandos pnpm/Yarn/Bun. */
|
|
32
|
+
export const extractNpmCommands = extractJsPackageCommands;
|
|
33
|
+
function jsScriptInvocation(manager, packageDir, script) {
|
|
34
|
+
const name = quoteShellArg(script);
|
|
35
|
+
if (manager === "npm") {
|
|
36
|
+
const prefix = packageDir === "." ? "npm" : `npm --prefix ${quoteShellArg(packageDir)}`;
|
|
37
|
+
return NPM_DIRECT_LIFECYCLE.has(script) ? `${prefix} ${name}` : `${prefix} run ${name}`;
|
|
38
|
+
}
|
|
39
|
+
if (manager === "pnpm") {
|
|
40
|
+
const prefix = packageDir === "." ? "pnpm" : `pnpm --dir ${quoteShellArg(packageDir)}`;
|
|
41
|
+
return NPM_DIRECT_LIFECYCLE.has(script) ? `${prefix} ${name}` : `${prefix} run ${name}`;
|
|
42
|
+
}
|
|
43
|
+
if (manager === "yarn") {
|
|
44
|
+
const prefix = packageDir === "." ? "yarn" : `yarn --cwd ${quoteShellArg(packageDir)}`;
|
|
45
|
+
return `${prefix} run ${name}`;
|
|
46
|
+
}
|
|
47
|
+
const prefix = packageDir === "." ? "bun" : `bun --cwd ${quoteShellArg(packageDir)}`;
|
|
48
|
+
return `${prefix} run ${name}`;
|
|
49
|
+
}
|
|
50
|
+
function quoteShellArg(value) {
|
|
51
|
+
return /\s/.test(value) ? JSON.stringify(value) : value;
|
|
52
|
+
}
|
|
19
53
|
export function extractMakeTargets(signals) {
|
|
20
54
|
const makefile = signals.makefile;
|
|
21
55
|
if (!makefile)
|
|
@@ -178,9 +212,190 @@ export function extractStructure(signals, lang) {
|
|
|
178
212
|
return note ? { dir: `${dir}/`, note } : { dir: `${dir}/` };
|
|
179
213
|
});
|
|
180
214
|
}
|
|
215
|
+
const TEST_DIR_NAMES = new Set(["test", "tests", "spec", "specs", "__tests__"]);
|
|
216
|
+
const MEANINGFUL_TEST_CHILDREN = new Set(["acceptance", "integration", "unit", "e2e"]);
|
|
217
|
+
const AUXILIARY_FACT_DIRS = new Set([
|
|
218
|
+
"docs", "doc", "tools", "tool", "scripts", "script", "examples", "example",
|
|
219
|
+
"benchmark", "benchmarks", "fixture", "fixtures",
|
|
220
|
+
]);
|
|
221
|
+
function isAuxiliaryFactPath(file) {
|
|
222
|
+
return file.split(/[\\/]/).some((segment) => AUXILIARY_FACT_DIRS.has(segment.toLowerCase()));
|
|
223
|
+
}
|
|
224
|
+
export function detectTestDirs(files) {
|
|
225
|
+
const dirs = new Set();
|
|
226
|
+
for (const file of files) {
|
|
227
|
+
const segments = file.split(/[\\/]/).slice(0, -1);
|
|
228
|
+
for (let i = 0; i < segments.length; i++) {
|
|
229
|
+
if (TEST_DIR_NAMES.has(segments[i].toLowerCase())) {
|
|
230
|
+
dirs.add(segments.slice(0, i + 1).join("/") + "/");
|
|
231
|
+
// Solo conserva sublayouts inequívocos. Directorios como tests/static o
|
|
232
|
+
// tests/templates suelen ser fixtures, no ubicaciones independientes de tests.
|
|
233
|
+
const child = segments[i + 1]?.toLowerCase();
|
|
234
|
+
const languageLayout = segments[0]?.toLowerCase() === "src" && segments[1]?.toLowerCase() === "test";
|
|
235
|
+
if (child && (languageLayout || MEANINGFUL_TEST_CHILDREN.has(child))) {
|
|
236
|
+
dirs.add(segments.slice(0, i + 2).join("/") + "/");
|
|
237
|
+
}
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return [...dirs].sort();
|
|
243
|
+
}
|
|
244
|
+
export function detectEntrypoints(signals) {
|
|
245
|
+
const out = [];
|
|
246
|
+
if (signals.packageJson?.main) {
|
|
247
|
+
out.push({ label: "main", target: signals.packageJson.main, source: "package.json" });
|
|
248
|
+
}
|
|
249
|
+
const scriptsSection = signals.pyprojectToml?.match(/\[project\.scripts\]([\s\S]*?)(?:\n\[|$)/)?.[1];
|
|
250
|
+
if (scriptsSection) {
|
|
251
|
+
for (const match of scriptsSection.matchAll(/^\s*([\w.-]+)\s*=\s*["']([^"']+)["']/gm)) {
|
|
252
|
+
out.push({ label: match[1], target: match[2], source: "pyproject.toml" });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return out;
|
|
256
|
+
}
|
|
257
|
+
function evidencePath(file) {
|
|
258
|
+
return file.split(path.sep).join("/");
|
|
259
|
+
}
|
|
260
|
+
export function extractArchitectureFacts(signals, lang, testDirs = detectTestDirs(signals.files.filter((file) => !isAuxiliaryFactPath(file))), entrypoints = detectEntrypoints(signals)) {
|
|
261
|
+
const facts = [];
|
|
262
|
+
const normalized = signals.files.filter((file) => !isAuxiliaryFactPath(file)).map(evidencePath);
|
|
263
|
+
const add = (fact) => facts.push({ ...fact, scope: ".", confidence: "high" });
|
|
264
|
+
if (entrypoints.length > 0) {
|
|
265
|
+
const rendered = entrypoints.map((entry) => `${entry.label} -> ${entry.target}`).join(", ");
|
|
266
|
+
add({
|
|
267
|
+
kind: "entrypoint",
|
|
268
|
+
statement: lang === "es" ? `Puntos de entrada declarados: ${rendered}.` : `Declared entry points: ${rendered}.`,
|
|
269
|
+
evidence: [...new Set(entrypoints.map((entry) => entry.source))],
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (testDirs.length > 0) {
|
|
273
|
+
const testEvidence = [...new Set(testDirs.map((dir) => {
|
|
274
|
+
const directory = dir.replace(/\/$/, "");
|
|
275
|
+
return signals.hasDir(directory) ? dir : normalized.find((file) => file.startsWith(dir));
|
|
276
|
+
}).filter((f) => Boolean(f)))];
|
|
277
|
+
add({
|
|
278
|
+
kind: "tests",
|
|
279
|
+
statement: lang === "es"
|
|
280
|
+
? `Los tests se colocan en ${testDirs.join(", ")}.`
|
|
281
|
+
: `Tests are placed under ${testDirs.join(", ")}.`,
|
|
282
|
+
evidence: testEvidence,
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
const sourceRoots = ["src", "lib"].filter((dir) => signals.hasDir(dir) || normalized.some((file) => file.startsWith(`${dir}/`)));
|
|
286
|
+
const sourceLocations = sourceRoots.flatMap((dir) => dir === "src" && signals.hasDir("src/main") ? ["src/main/"] : [`${dir}/`]);
|
|
287
|
+
if (sourceLocations.length > 0) {
|
|
288
|
+
add({
|
|
289
|
+
kind: "source-layout",
|
|
290
|
+
statement: lang === "es"
|
|
291
|
+
? `El código principal vive en ${sourceLocations.join(", ")}.`
|
|
292
|
+
: `Primary source code lives under ${sourceLocations.join(", ")}.`,
|
|
293
|
+
evidence: sourceLocations.map((location) => {
|
|
294
|
+
const directory = location.replace(/\/$/, "");
|
|
295
|
+
return signals.hasDir(directory) ? location : normalized.find((file) => file.startsWith(location));
|
|
296
|
+
}).filter(Boolean),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
const layerPatterns = [
|
|
300
|
+
["controller", /(?:^|\/)(?:controllers?)(?:\/|$)/i],
|
|
301
|
+
["service", /(?:^|\/)(?:services?)(?:\/|$)/i],
|
|
302
|
+
["repository", /(?:^|\/)(?:repositories|repository)(?:\/|$)/i],
|
|
303
|
+
];
|
|
304
|
+
const layers = layerPatterns.map(([name, pattern]) => ({ name, file: normalized.find((file) => pattern.test(file)) }));
|
|
305
|
+
if (layers.every((layer) => layer.file)) {
|
|
306
|
+
add({
|
|
307
|
+
kind: "layers",
|
|
308
|
+
statement: lang === "es"
|
|
309
|
+
? "El repositorio separa controller, service y repository en capas distintas."
|
|
310
|
+
: "The repository separates controller, service and repository into distinct layers.",
|
|
311
|
+
evidence: layers.map((layer) => layer.file),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const workspaceManifests = (signals.packageJsons ?? []).filter((manifest) => manifest.path !== "package.json");
|
|
315
|
+
if (workspaceManifests.length > 0) {
|
|
316
|
+
add({
|
|
317
|
+
kind: "workspace",
|
|
318
|
+
statement: lang === "es"
|
|
319
|
+
? `El workspace contiene ${workspaceManifests.length} paquete(s) JavaScript/TypeScript anidado(s).`
|
|
320
|
+
: `The workspace contains ${workspaceManifests.length} nested JavaScript/TypeScript package(s).`,
|
|
321
|
+
evidence: workspaceManifests.map((manifest) => manifest.path),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
return facts;
|
|
325
|
+
}
|
|
326
|
+
function uniqueConfigValue(content, key) {
|
|
327
|
+
const values = [...content.matchAll(new RegExp(`^\\s*${key}\\s*=\\s*([^#;\\r\\n]+)`, "gmi"))]
|
|
328
|
+
.map((match) => match[1].trim().toLowerCase());
|
|
329
|
+
const unique = [...new Set(values)];
|
|
330
|
+
return unique.length === 1 ? unique[0] : undefined;
|
|
331
|
+
}
|
|
332
|
+
export function extractConventionFacts(signals, lang) {
|
|
333
|
+
const facts = [];
|
|
334
|
+
const rootFiles = (signals.guidanceFiles ?? []).filter((file) => !evidencePath(file.path).includes("/"));
|
|
335
|
+
const get = (name) => rootFiles.find((file) => file.path.toLowerCase() === name.toLowerCase())?.content;
|
|
336
|
+
const add = (fact) => facts.push({ ...fact, scope: ".", confidence: "high" });
|
|
337
|
+
const editorConfig = get(".editorconfig");
|
|
338
|
+
if (editorConfig) {
|
|
339
|
+
const style = uniqueConfigValue(editorConfig, "indent_style");
|
|
340
|
+
const rawSize = uniqueConfigValue(editorConfig, "indent_size");
|
|
341
|
+
const size = rawSize && /^\d+$/.test(rawSize) ? rawSize : undefined;
|
|
342
|
+
if (style)
|
|
343
|
+
add({
|
|
344
|
+
kind: "formatting",
|
|
345
|
+
statement: lang === "es"
|
|
346
|
+
? `La indentación usa ${style === "space" ? "espacios" : style === "tab" ? "tabuladores" : style}${size ? ` con tamaño ${size}` : ""}.`
|
|
347
|
+
: `Indentation uses ${style === "space" ? "spaces" : style === "tab" ? "tabs" : style}${size ? ` with size ${size}` : ""}.`,
|
|
348
|
+
evidence: [".editorconfig"],
|
|
349
|
+
});
|
|
350
|
+
const finalNewline = uniqueConfigValue(editorConfig, "insert_final_newline");
|
|
351
|
+
if (finalNewline === "true")
|
|
352
|
+
add({
|
|
353
|
+
kind: "formatting",
|
|
354
|
+
statement: lang === "es" ? "Los archivos deben terminar con una línea nueva." : "Files must end with a final newline.",
|
|
355
|
+
evidence: [".editorconfig"],
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
const tsconfig = get("tsconfig.json");
|
|
359
|
+
if (tsconfig && /["']strict["']\s*:\s*true\b/i.test(tsconfig))
|
|
360
|
+
add({
|
|
361
|
+
kind: "typescript",
|
|
362
|
+
statement: lang === "es" ? "TypeScript tiene activado el modo strict." : "TypeScript strict mode is enabled.",
|
|
363
|
+
evidence: ["tsconfig.json"],
|
|
364
|
+
});
|
|
365
|
+
const pyproject = get("pyproject.toml");
|
|
366
|
+
if (pyproject) {
|
|
367
|
+
for (const tool of ["ruff", "black"]) {
|
|
368
|
+
const section = pyproject.match(new RegExp(`(?:^|\\n)\\[tool\\.${tool}\\]([\\s\\S]*?)(?:\\n\\[|$)`, "i"))?.[1];
|
|
369
|
+
const lineLength = section?.match(/(?:^|\n)\s*line-length\s*=\s*(\d+)/i)?.[1];
|
|
370
|
+
if (lineLength)
|
|
371
|
+
add({
|
|
372
|
+
kind: "python-style",
|
|
373
|
+
statement: lang === "es"
|
|
374
|
+
? `${tool} configura una longitud máxima de línea de ${lineLength}.`
|
|
375
|
+
: `${tool} configures a maximum line length of ${lineLength}.`,
|
|
376
|
+
evidence: ["pyproject.toml"],
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
const contributing = get("CONTRIBUTING.md");
|
|
381
|
+
if (contributing) {
|
|
382
|
+
const directives = contributing.split(/\r?\n/)
|
|
383
|
+
.map((line) => line.match(/^\s*[-*]\s+(.{1,180})$/)?.[1]?.trim())
|
|
384
|
+
.filter((line) => Boolean(line))
|
|
385
|
+
.filter((line) => /\b(?:must|should|required|do not|don't|run|use|follow|debe|debes|ejecuta|usa|sigue|no añadas)\b/i.test(line))
|
|
386
|
+
.slice(0, 5);
|
|
387
|
+
for (const directive of directives)
|
|
388
|
+
add({
|
|
389
|
+
kind: "contributing",
|
|
390
|
+
statement: directive,
|
|
391
|
+
evidence: ["CONTRIBUTING.md"],
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
return facts;
|
|
395
|
+
}
|
|
181
396
|
export function buildRepoFacts(signals, lang) {
|
|
182
397
|
const allCommands = [
|
|
183
|
-
...
|
|
398
|
+
...extractJsPackageCommands(signals),
|
|
184
399
|
...extractComposerCommands(signals),
|
|
185
400
|
...extractMakeTargets(signals),
|
|
186
401
|
...extractMixAliases(signals),
|
|
@@ -188,11 +403,18 @@ export function buildRepoFacts(signals, lang) {
|
|
|
188
403
|
];
|
|
189
404
|
const { kept, omitted } = filterCommands(allCommands);
|
|
190
405
|
const { commands: ciCommands, omittedCount: omittedCiCount } = extractCiCommands(signals);
|
|
406
|
+
const testDirs = detectTestDirs(signals.files.filter((file) => !isAuxiliaryFactPath(file)));
|
|
407
|
+
const entrypoints = detectEntrypoints(signals);
|
|
191
408
|
return {
|
|
192
409
|
commands: kept,
|
|
193
410
|
omittedCommands: omitted,
|
|
194
411
|
structure: extractStructure(signals, lang),
|
|
195
412
|
ciCommands,
|
|
196
413
|
omittedCiCount,
|
|
414
|
+
canonical: selectCanonicalCommands(signals, kept, ciCommands),
|
|
415
|
+
testDirs,
|
|
416
|
+
entrypoints,
|
|
417
|
+
architectureFacts: extractArchitectureFacts(signals, lang, testDirs, entrypoints),
|
|
418
|
+
conventionFacts: extractConventionFacts(signals, lang),
|
|
197
419
|
};
|
|
198
420
|
}
|
package/dist/core/scanner.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
const IGNORED_DIRS = new Set([
|
|
3
|
+
const IGNORED_DIRS = new Set([
|
|
4
|
+
"node_modules", ".git", ".hg", ".svn",
|
|
5
|
+
"dist", "build", "out", "target", "coverage", "vendor",
|
|
6
|
+
".venv", "venv", ".tox", "__pycache__",
|
|
7
|
+
".next", ".nuxt", ".svelte-kit", ".turbo", ".cache",
|
|
8
|
+
".gradle", ".dart_tool",
|
|
9
|
+
]);
|
|
4
10
|
const MAX_DEPTH = 4;
|
|
5
11
|
function walk(rootPath) {
|
|
6
12
|
const results = [];
|
|
@@ -14,6 +20,9 @@ function walk(rootPath) {
|
|
|
14
20
|
catch {
|
|
15
21
|
return;
|
|
16
22
|
}
|
|
23
|
+
// Filesystem enumeration order is not guaranteed. Keep discovery stable so
|
|
24
|
+
// precedence-sensitive facts (for example, the first CI command) are reproducible.
|
|
25
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
17
26
|
for (const entry of entries) {
|
|
18
27
|
if (entry.isDirectory()) {
|
|
19
28
|
if (IGNORED_DIRS.has(entry.name))
|
|
@@ -21,6 +30,8 @@ function walk(rootPath) {
|
|
|
21
30
|
recurse(path.join(dir, entry.name), depth + 1);
|
|
22
31
|
}
|
|
23
32
|
else {
|
|
33
|
+
if (entry.name.includes(".generated."))
|
|
34
|
+
continue;
|
|
24
35
|
results.push(path.relative(rootPath, path.join(dir, entry.name)));
|
|
25
36
|
}
|
|
26
37
|
}
|
|
@@ -43,6 +54,56 @@ function readJsonIfExists(filePath) {
|
|
|
43
54
|
return undefined;
|
|
44
55
|
}
|
|
45
56
|
}
|
|
57
|
+
function toPackageJsonManifest(raw, relativePath) {
|
|
58
|
+
return {
|
|
59
|
+
path: relativePath.split(path.sep).join("/"),
|
|
60
|
+
name: raw.name,
|
|
61
|
+
main: typeof raw.main === "string" ? raw.main : undefined,
|
|
62
|
+
dependencies: raw.dependencies ?? {},
|
|
63
|
+
devDependencies: raw.devDependencies ?? {},
|
|
64
|
+
scripts: raw.scripts ?? {},
|
|
65
|
+
moduleType: raw.type === "module" ? "module" : "commonjs",
|
|
66
|
+
packageManager: parsePackageManager(raw.packageManager),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function parsePackageManager(value) {
|
|
70
|
+
if (typeof value !== "string")
|
|
71
|
+
return undefined;
|
|
72
|
+
const name = value.trim().split("@")[0].toLowerCase();
|
|
73
|
+
return name === "npm" || name === "pnpm" || name === "yarn" || name === "bun" ? name : undefined;
|
|
74
|
+
}
|
|
75
|
+
const LOCK_MANAGERS = {
|
|
76
|
+
"package-lock.json": "npm",
|
|
77
|
+
"npm-shrinkwrap.json": "npm",
|
|
78
|
+
"pnpm-lock.yaml": "pnpm",
|
|
79
|
+
"yarn.lock": "yarn",
|
|
80
|
+
"bun.lock": "bun",
|
|
81
|
+
"bun.lockb": "bun",
|
|
82
|
+
};
|
|
83
|
+
function managerFromClosestLock(manifestPath, normalizedFiles) {
|
|
84
|
+
let dir = path.posix.dirname(manifestPath.split(path.sep).join("/"));
|
|
85
|
+
while (true) {
|
|
86
|
+
for (const [lockName, manager] of Object.entries(LOCK_MANAGERS)) {
|
|
87
|
+
const candidate = dir === "." ? lockName : `${dir}/${lockName}`;
|
|
88
|
+
if (normalizedFiles.has(candidate))
|
|
89
|
+
return manager;
|
|
90
|
+
}
|
|
91
|
+
if (dir === ".")
|
|
92
|
+
return undefined;
|
|
93
|
+
dir = path.posix.dirname(dir);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
function managerFromClosestDeclaration(manifestPath, declarations) {
|
|
97
|
+
let dir = path.posix.dirname(manifestPath);
|
|
98
|
+
while (true) {
|
|
99
|
+
const manager = declarations.get(dir);
|
|
100
|
+
if (manager)
|
|
101
|
+
return manager;
|
|
102
|
+
if (dir === ".")
|
|
103
|
+
return undefined;
|
|
104
|
+
dir = path.posix.dirname(dir);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
46
107
|
function readTextIfExists(filePath) {
|
|
47
108
|
if (!fs.existsSync(filePath))
|
|
48
109
|
return undefined;
|
|
@@ -76,6 +137,8 @@ const NON_PROJECT_DIRS = new Set([
|
|
|
76
137
|
"example",
|
|
77
138
|
"benchmark",
|
|
78
139
|
"benchmarks",
|
|
140
|
+
"fixture",
|
|
141
|
+
"fixtures",
|
|
79
142
|
]);
|
|
80
143
|
function isUnderNonProjectDir(relativePath) {
|
|
81
144
|
const segments = relativePath.split(path.sep).slice(0, -1);
|
|
@@ -105,17 +168,45 @@ function pickShallowest(paths) {
|
|
|
105
168
|
export function scanRepo(rootPath) {
|
|
106
169
|
const files = walk(rootPath);
|
|
107
170
|
const fileSet = new Set(files.map((f) => f.split(path.sep).join("/")));
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
const
|
|
171
|
+
// Keep every project package manifest, not only the root workspace manifest. Root
|
|
172
|
+
// package.json files are often workspace glue and contain none of the dependencies
|
|
173
|
+
// that reveal React/Vitest/etc. Manifests under docs/examples/tooling are excluded to
|
|
174
|
+
// avoid treating auxiliary demos, fixtures and documentation builds as the project.
|
|
175
|
+
const packageJsonPaths = files.filter((f) => path.basename(f) === "package.json" && !isUnderNonProjectDir(f));
|
|
176
|
+
const packageJsons = packageJsonPaths
|
|
177
|
+
.map((relativePath) => {
|
|
178
|
+
const raw = readJsonIfExists(path.join(rootPath, relativePath));
|
|
179
|
+
return raw ? toPackageJsonManifest(raw, relativePath) : undefined;
|
|
180
|
+
})
|
|
181
|
+
.filter((manifest) => manifest !== undefined)
|
|
182
|
+
.sort((a, b) => {
|
|
183
|
+
const depth = a.path.split("/").length - b.path.split("/").length;
|
|
184
|
+
return depth || a.path.localeCompare(b.path);
|
|
185
|
+
});
|
|
186
|
+
const declaredManagers = new Map(packageJsons
|
|
187
|
+
.filter((manifest) => manifest.packageManager !== undefined)
|
|
188
|
+
.map((manifest) => [path.posix.dirname(manifest.path), manifest.packageManager]));
|
|
189
|
+
// A nested lock takes precedence over an ancestor workspace declaration because it
|
|
190
|
+
// denotes an independently installed package. Both lookups are O(path depth).
|
|
191
|
+
for (const manifest of packageJsons) {
|
|
192
|
+
if (manifest.packageManager)
|
|
193
|
+
continue;
|
|
194
|
+
manifest.packageManager =
|
|
195
|
+
managerFromClosestLock(manifest.path, fileSet) ??
|
|
196
|
+
managerFromClosestDeclaration(manifest.path, declaredManagers);
|
|
197
|
+
}
|
|
198
|
+
const primaryPackageJson = packageJsons[0];
|
|
199
|
+
const packageJson = primaryPackageJson
|
|
113
200
|
? {
|
|
114
|
-
name:
|
|
115
|
-
dependencies:
|
|
116
|
-
devDependencies:
|
|
117
|
-
scripts
|
|
118
|
-
|
|
201
|
+
name: primaryPackageJson.name,
|
|
202
|
+
dependencies: Object.assign({}, ...packageJsons.map((p) => p.dependencies)),
|
|
203
|
+
devDependencies: Object.assign({}, ...packageJsons.map((p) => p.devDependencies)),
|
|
204
|
+
// Root scripts remain the default command surface. Repo facts reads packageJsons
|
|
205
|
+
// directly and adds executable --prefix commands for nested packages.
|
|
206
|
+
scripts: primaryPackageJson.scripts,
|
|
207
|
+
moduleType: primaryPackageJson.moduleType,
|
|
208
|
+
packageManager: primaryPackageJson.packageManager,
|
|
209
|
+
main: primaryPackageJson.main,
|
|
119
210
|
}
|
|
120
211
|
: undefined;
|
|
121
212
|
const composerJsonPath = findFirst(files, "composer.json");
|
|
@@ -147,37 +238,45 @@ export function scanRepo(rootPath) {
|
|
|
147
238
|
// Multi-module Maven/Gradle projects (parent + child modules) each have their own
|
|
148
239
|
// pom.xml/build.gradle, and the module that actually declares the framework/Kotlin
|
|
149
240
|
// plugin/test runner isn't necessarily the shallowest one. Aggregate all of them.
|
|
150
|
-
const
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
const
|
|
241
|
+
const projectFiles = files.filter((file) => !isUnderNonProjectDir(file));
|
|
242
|
+
const pomPaths = findAllByNames(projectFiles, ["pom.xml"]);
|
|
243
|
+
const buildGradlePaths = findAllByNames(projectFiles, ["build.gradle", "build.gradle.kts"]);
|
|
244
|
+
const gemfilePath = findFirst(projectFiles, "Gemfile");
|
|
245
|
+
const goModPath = findFirst(projectFiles, "go.mod");
|
|
246
|
+
const cargoTomlPath = findFirst(projectFiles, "Cargo.toml");
|
|
155
247
|
// .NET solutions routinely split into several .csproj files (domain/infra/web/tests);
|
|
156
248
|
// picking just the "shallowest" one is close to arbitrary and often lands on a plain
|
|
157
249
|
// class library that has neither the web framework nor the test runner reference.
|
|
158
250
|
// Concatenate all of them so detection can find those references wherever they live.
|
|
159
|
-
const csprojPaths = findAllByExtension(
|
|
160
|
-
const packageSwiftPath = findFirst(
|
|
251
|
+
const csprojPaths = findAllByExtension(projectFiles, ".csproj");
|
|
252
|
+
const packageSwiftPath = findFirst(projectFiles, "Package.swift");
|
|
161
253
|
// Melos/pub workspaces have a root pubspec.yaml that's just workspace glue (no
|
|
162
254
|
// `flutter`/`flutter_test` dependency) plus one real pubspec.yaml per package under
|
|
163
255
|
// packages/*. Aggregate all of them so the actual framework/test runner is found.
|
|
164
|
-
const pubspecYamlPaths = findAllByNames(
|
|
165
|
-
const cmakeListsPath = findFirst(
|
|
256
|
+
const pubspecYamlPaths = findAllByNames(projectFiles, ["pubspec.yaml"]);
|
|
257
|
+
const cmakeListsPath = findFirst(projectFiles, "CMakeLists.txt");
|
|
166
258
|
// Un Makefile que solo existe bajo docs/, tools/, etc. es tooling auxiliar (p. ej. el
|
|
167
259
|
// Makefile de Sphinx en docs/ de Flask): sus targets no se pueden ejecutar desde la
|
|
168
260
|
// raíz y no describen el proyecto. Mismo criterio que los manifiestos Python.
|
|
169
261
|
const makefilePath = findFirstPreferringRealProjectDirs(files, "Makefile") ??
|
|
170
262
|
findFirstPreferringRealProjectDirs(files, "makefile");
|
|
171
|
-
const mixExsPath = findFirst(
|
|
172
|
-
const buildSbtPath = findFirst(
|
|
173
|
-
const rDescriptionPath = findFirst(
|
|
174
|
-
const renvLockPath = findFirst(
|
|
175
|
-
const toxIniPath = findFirst(
|
|
263
|
+
const mixExsPath = findFirst(projectFiles, "mix.exs");
|
|
264
|
+
const buildSbtPath = findFirst(projectFiles, "build.sbt");
|
|
265
|
+
const rDescriptionPath = findFirst(projectFiles, "DESCRIPTION");
|
|
266
|
+
const renvLockPath = findFirst(projectFiles, "renv.lock");
|
|
267
|
+
const toxIniPath = findFirst(projectFiles, "tox.ini");
|
|
176
268
|
const workflowPaths = files.filter((f) => {
|
|
177
269
|
const normalized = f.split(path.sep).join("/");
|
|
178
270
|
return (normalized.startsWith(".github/workflows/") &&
|
|
179
271
|
(normalized.endsWith(".yml") || normalized.endsWith(".yaml")));
|
|
180
272
|
});
|
|
273
|
+
const guidanceNames = new Set([
|
|
274
|
+
"CONTRIBUTING.md", ".editorconfig", "tsconfig.json", "pyproject.toml",
|
|
275
|
+
]);
|
|
276
|
+
const guidancePaths = files.filter((file) => {
|
|
277
|
+
const normalized = file.split(path.sep).join("/");
|
|
278
|
+
return guidanceNames.has(path.posix.basename(normalized));
|
|
279
|
+
});
|
|
181
280
|
return {
|
|
182
281
|
rootPath,
|
|
183
282
|
files,
|
|
@@ -185,6 +284,7 @@ export function scanRepo(rootPath) {
|
|
|
185
284
|
hasDir: (relativeDir) => fs.existsSync(path.join(rootPath, relativeDir)) &&
|
|
186
285
|
fs.statSync(path.join(rootPath, relativeDir)).isDirectory(),
|
|
187
286
|
packageJson,
|
|
287
|
+
packageJsons,
|
|
188
288
|
pyprojectToml: pyprojectPath ? readTextIfExists(path.join(rootPath, pyprojectPath)) : undefined,
|
|
189
289
|
requirementsTxt: requirementsPath
|
|
190
290
|
? readTextIfExists(path.join(rootPath, requirementsPath))
|
|
@@ -214,5 +314,8 @@ export function scanRepo(rootPath) {
|
|
|
214
314
|
content: readTextIfExists(path.join(rootPath, p)),
|
|
215
315
|
}))
|
|
216
316
|
.filter((w) => w.content !== undefined),
|
|
317
|
+
guidanceFiles: guidancePaths
|
|
318
|
+
.map((p) => ({ path: p.split(path.sep).join("/"), content: readTextIfExists(path.join(rootPath, p)) }))
|
|
319
|
+
.filter((f) => f.content !== undefined),
|
|
217
320
|
};
|
|
218
321
|
}
|