agent-rules-init 0.2.1 → 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 +38 -1
- package/dist/cli.js +203 -40
- 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 +47 -0
- package/dist/core/i18n.js +207 -0
- package/dist/core/llm-bridge.d.ts +5 -1
- package/dist/core/llm-bridge.js +56 -4
- 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/prompt-engine.d.ts +3 -1
- package/dist/core/prompt-engine.js +19 -14
- package/dist/core/repo-facts.d.ts +30 -0
- package/dist/core/repo-facts.js +420 -0
- package/dist/core/scanner.js +153 -27
- package/dist/core/templates.d.ts +6 -4
- package/dist/core/templates.js +127 -29
- package/dist/core/types.d.ts +84 -2
- package/dist/core/writer.d.ts +1 -1
- package/dist/core/writer.js +6 -4
- package/dist/packs/cpp.js +33 -27
- package/dist/packs/csharp.js +32 -27
- package/dist/packs/dart.js +30 -31
- package/dist/packs/elixir.js +31 -27
- package/dist/packs/go.js +31 -27
- package/dist/packs/java.js +82 -30
- package/dist/packs/js-ts.js +150 -37
- package/dist/packs/kotlin.js +56 -31
- package/dist/packs/php.js +30 -27
- package/dist/packs/python.js +107 -34
- package/dist/packs/r.js +30 -27
- package/dist/packs/ruby.js +36 -27
- package/dist/packs/rust.js +33 -27
- package/dist/packs/scala.js +32 -27
- package/dist/packs/swift.js +31 -27
- package/package.json +7 -4
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as clack from "@clack/prompts";
|
|
2
|
+
import { UI, detectLang } from "./i18n.js";
|
|
2
3
|
const FIELDS = ["framework", "testRunner", "linter", "packageManager"];
|
|
3
|
-
export function collectLowConfidenceQuestions(detections) {
|
|
4
|
+
export function collectLowConfidenceQuestions(detections, lang) {
|
|
5
|
+
const ui = UI[lang];
|
|
4
6
|
const questions = [];
|
|
5
7
|
for (const detection of detections) {
|
|
6
8
|
for (const field of FIELDS) {
|
|
@@ -9,7 +11,7 @@ export function collectLowConfidenceQuestions(detections) {
|
|
|
9
11
|
questions.push({
|
|
10
12
|
packId: detection.packId,
|
|
11
13
|
field,
|
|
12
|
-
message:
|
|
14
|
+
message: ui.question(ui.fieldLabels[field], detection.language),
|
|
13
15
|
});
|
|
14
16
|
}
|
|
15
17
|
}
|
|
@@ -19,18 +21,21 @@ export function collectLowConfidenceQuestions(detections) {
|
|
|
19
21
|
export function hasInteractiveTty() {
|
|
20
22
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
21
23
|
}
|
|
22
|
-
export
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
clack.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
24
|
+
export function makeDefaultPromptFn(lang) {
|
|
25
|
+
return async (message) => {
|
|
26
|
+
if (!hasInteractiveTty()) {
|
|
27
|
+
console.warn(UI[lang].skippedQuestion(message));
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
const answer = await clack.text({ message });
|
|
31
|
+
if (clack.isCancel(answer)) {
|
|
32
|
+
clack.cancel(UI[lang].cancelled);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
return answer;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
export const defaultPromptFn = (message) => makeDefaultPromptFn(detectLang())(message);
|
|
34
39
|
export async function askQuestions(questions, promptFn = defaultPromptFn) {
|
|
35
40
|
const answers = {};
|
|
36
41
|
for (const question of questions) {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Lang } from "./i18n.js";
|
|
2
|
+
import type { CiCommand, ArchitectureFact, CommandEntry, CommandSource, ConventionFact, DirEntry, RepoFacts, RepoSignals } from "./types.js";
|
|
3
|
+
export declare function extractJsPackageCommands(signals: RepoSignals): CommandEntry[];
|
|
4
|
+
/** @deprecated Conservado como alias de API; también devuelve comandos pnpm/Yarn/Bun. */
|
|
5
|
+
export declare const extractNpmCommands: typeof extractJsPackageCommands;
|
|
6
|
+
export declare function extractMakeTargets(signals: RepoSignals): CommandEntry[];
|
|
7
|
+
export declare function extractMixAliases(signals: RepoSignals): CommandEntry[];
|
|
8
|
+
export declare function extractToxEnvs(signals: RepoSignals): CommandEntry[];
|
|
9
|
+
export declare function extractComposerCommands(signals: RepoSignals): CommandEntry[];
|
|
10
|
+
export declare function filterCommands(entries: CommandEntry[]): {
|
|
11
|
+
kept: CommandEntry[];
|
|
12
|
+
omitted: {
|
|
13
|
+
source: CommandSource;
|
|
14
|
+
count: number;
|
|
15
|
+
}[];
|
|
16
|
+
};
|
|
17
|
+
export declare function extractCiCommands(signals: RepoSignals): {
|
|
18
|
+
commands: CiCommand[];
|
|
19
|
+
omittedCount: number;
|
|
20
|
+
};
|
|
21
|
+
export declare function extractStructure(signals: RepoSignals, lang: Lang): DirEntry[];
|
|
22
|
+
export declare function detectTestDirs(files: string[]): string[];
|
|
23
|
+
export declare function detectEntrypoints(signals: RepoSignals): RepoFacts["entrypoints"];
|
|
24
|
+
export declare function extractArchitectureFacts(signals: RepoSignals, lang: Lang, testDirs?: string[], entrypoints?: {
|
|
25
|
+
label: string;
|
|
26
|
+
target: string;
|
|
27
|
+
source: string;
|
|
28
|
+
}[]): ArchitectureFact[];
|
|
29
|
+
export declare function extractConventionFacts(signals: RepoSignals, lang: Lang): ConventionFact[];
|
|
30
|
+
export declare function buildRepoFacts(signals: RepoSignals, lang: Lang): RepoFacts;
|
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { parse } from "yaml";
|
|
3
|
+
import { UI } from "./i18n.js";
|
|
4
|
+
import { selectCanonicalCommands } from "./canonical-commands.js";
|
|
5
|
+
const NPM_DIRECT_LIFECYCLE = new Set(["test", "start", "stop", "restart"]);
|
|
6
|
+
export function extractJsPackageCommands(signals) {
|
|
7
|
+
const entries = [];
|
|
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
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return entries;
|
|
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
|
+
}
|
|
53
|
+
export function extractMakeTargets(signals) {
|
|
54
|
+
const makefile = signals.makefile;
|
|
55
|
+
if (!makefile)
|
|
56
|
+
return [];
|
|
57
|
+
const targets = new Set();
|
|
58
|
+
for (const line of makefile.split(/\r?\n/)) {
|
|
59
|
+
// Un target va a inicio de línea (las recetas van indentadas con tab) y su nombre
|
|
60
|
+
// no lleva %, ni empieza por "." (targets especiales tipo .PHONY). El (?!=) evita
|
|
61
|
+
// asignaciones ":=". La clase de caracteres ya excluye espacios, con lo que
|
|
62
|
+
// "CFLAGS :=", los comentarios "#" y las URLs en comentarios tampoco matchean.
|
|
63
|
+
const match = /^([A-Za-z0-9_/-][A-Za-z0-9_./-]*)\s*:(?!=)/.exec(line);
|
|
64
|
+
if (!match)
|
|
65
|
+
continue;
|
|
66
|
+
targets.add(match[1]);
|
|
67
|
+
}
|
|
68
|
+
return [...targets].map((target) => ({ source: "make", invocation: `make ${target}` }));
|
|
69
|
+
}
|
|
70
|
+
export function extractMixAliases(signals) {
|
|
71
|
+
const mixExs = signals.mixExs;
|
|
72
|
+
if (!mixExs)
|
|
73
|
+
return [];
|
|
74
|
+
// Cuerpo de la función aliases (defp aliases do ... end). Si no existe con esa
|
|
75
|
+
// forma, no se emite nada — omitir antes que inventar.
|
|
76
|
+
const fnMatch = mixExs.match(/defp?\s+aliases\s*(?:\(\))?\s*do([\s\S]*?)\n\s*end/);
|
|
77
|
+
if (!fnMatch)
|
|
78
|
+
return [];
|
|
79
|
+
const body = fnMatch[1];
|
|
80
|
+
const names = new Set();
|
|
81
|
+
// Claves del keyword list cuyo valor empieza por lista o string: `setup: [...]`,
|
|
82
|
+
// `"ecto.setup": [...]`. Un alias con valor función (&fun/1) se omite.
|
|
83
|
+
for (const match of body.matchAll(/(?:"([^"]+)"|([a-z_][a-zA-Z0-9_.]*)):\s*(?=[["'])/g)) {
|
|
84
|
+
names.add(match[1] ?? match[2]);
|
|
85
|
+
}
|
|
86
|
+
return [...names].map((alias) => ({ source: "mix", invocation: `mix ${alias}` }));
|
|
87
|
+
}
|
|
88
|
+
export function extractToxEnvs(signals) {
|
|
89
|
+
const toxIni = signals.toxIni;
|
|
90
|
+
if (!toxIni)
|
|
91
|
+
return [];
|
|
92
|
+
const match = toxIni.match(/^[ \t]*env_?list\s*=\s*(.+(?:\n[ \t]+\S.*)*)/m);
|
|
93
|
+
if (!match)
|
|
94
|
+
return [];
|
|
95
|
+
const envs = match[1]
|
|
96
|
+
.split(/[,\s]+/)
|
|
97
|
+
.map((env) => env.trim())
|
|
98
|
+
// Los envs con factores generadores (py3{10,11}) se omiten en vez de expandirlos.
|
|
99
|
+
// El split por comas los parte en trozos ("py3{10", "11}"), así que basta con
|
|
100
|
+
// descartar cualquier trozo que contenga una llave.
|
|
101
|
+
.filter((env) => env !== "" && !env.startsWith("#") && !/[{}]/.test(env));
|
|
102
|
+
return [...new Set(envs)].map((env) => ({ source: "tox", invocation: `tox -e ${env}` }));
|
|
103
|
+
}
|
|
104
|
+
export function extractComposerCommands(signals) {
|
|
105
|
+
const scripts = signals.composerJson?.scripts ?? {};
|
|
106
|
+
const entries = [];
|
|
107
|
+
for (const [name, raw] of Object.entries(scripts)) {
|
|
108
|
+
const parts = Array.isArray(raw)
|
|
109
|
+
? raw.filter((p) => typeof p === "string")
|
|
110
|
+
: typeof raw === "string"
|
|
111
|
+
? [raw]
|
|
112
|
+
: [];
|
|
113
|
+
if (parts.length === 0)
|
|
114
|
+
continue;
|
|
115
|
+
entries.push({ source: "composer", invocation: `composer ${name}`, detail: parts.join(" && ") });
|
|
116
|
+
}
|
|
117
|
+
return entries;
|
|
118
|
+
}
|
|
119
|
+
const WELL_KNOWN_NAMES = new Set([
|
|
120
|
+
"test", "tests", "build", "lint", "fmt", "format", "check", "dev", "start",
|
|
121
|
+
"typecheck", "ci", "coverage", "e2e", "unit", "docs", "clean", "install",
|
|
122
|
+
"setup", "release", "watch", "fix", "all",
|
|
123
|
+
]);
|
|
124
|
+
const MAX_COMMANDS_PER_SOURCE = 15;
|
|
125
|
+
function invocationName(entry) {
|
|
126
|
+
const parts = entry.invocation.split(" ");
|
|
127
|
+
return parts[parts.length - 1];
|
|
128
|
+
}
|
|
129
|
+
export function filterCommands(entries) {
|
|
130
|
+
const kept = [];
|
|
131
|
+
const omitted = [];
|
|
132
|
+
const sources = [...new Set(entries.map((e) => e.source))];
|
|
133
|
+
for (const source of sources) {
|
|
134
|
+
const group = entries.filter((e) => e.source === source);
|
|
135
|
+
const wellKnown = group.filter((e) => WELL_KNOWN_NAMES.has(invocationName(e)));
|
|
136
|
+
const rest = group.filter((e) => !WELL_KNOWN_NAMES.has(invocationName(e)));
|
|
137
|
+
const keptGroup = new Set([...wellKnown, ...rest].slice(0, MAX_COMMANDS_PER_SOURCE));
|
|
138
|
+
// Se emite en el orden original del manifiesto, no con los conocidos primero.
|
|
139
|
+
kept.push(...group.filter((e) => keptGroup.has(e)));
|
|
140
|
+
const omittedCount = group.length - keptGroup.size;
|
|
141
|
+
if (omittedCount > 0)
|
|
142
|
+
omitted.push({ source, count: omittedCount });
|
|
143
|
+
}
|
|
144
|
+
return { kept, omitted };
|
|
145
|
+
}
|
|
146
|
+
const MAX_CI_COMMANDS = 30;
|
|
147
|
+
// Líneas de un script multilínea que son control de flujo del shell, no comandos:
|
|
148
|
+
// `if [[ ... ]]; then`, `else`, `fi`, `for x in ...; do`, `done`, `case`/`esac`...
|
|
149
|
+
const SHELL_CONTROL_FLOW = /^(?:if|elif|else|fi|then|do|done|for|while|until|case|esac)\b|^(?:fi|then|else|done|esac)$/;
|
|
150
|
+
function isShellControlFlow(line) {
|
|
151
|
+
return SHELL_CONTROL_FLOW.test(line);
|
|
152
|
+
}
|
|
153
|
+
export function extractCiCommands(signals) {
|
|
154
|
+
const seen = new Map(); // comando -> workflow de origen
|
|
155
|
+
for (const workflow of signals.githubWorkflows ?? []) {
|
|
156
|
+
let doc;
|
|
157
|
+
try {
|
|
158
|
+
doc = parse(workflow.content);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
continue; // YAML inválido: omitir antes que inventar
|
|
162
|
+
}
|
|
163
|
+
if (!doc || typeof doc !== "object")
|
|
164
|
+
continue;
|
|
165
|
+
const jobs = doc.jobs;
|
|
166
|
+
if (!jobs || typeof jobs !== "object")
|
|
167
|
+
continue;
|
|
168
|
+
const workflowName = workflow.path.split("/").pop() ?? workflow.path;
|
|
169
|
+
for (const job of Object.values(jobs)) {
|
|
170
|
+
if (!job || typeof job !== "object")
|
|
171
|
+
continue;
|
|
172
|
+
const steps = job.steps;
|
|
173
|
+
if (!Array.isArray(steps))
|
|
174
|
+
continue;
|
|
175
|
+
for (const step of steps) {
|
|
176
|
+
if (!step || typeof step !== "object")
|
|
177
|
+
continue;
|
|
178
|
+
const run = step.run;
|
|
179
|
+
if (typeof run !== "string")
|
|
180
|
+
continue;
|
|
181
|
+
for (const rawLine of run.split(/\r?\n/)) {
|
|
182
|
+
const line = rawLine.trim();
|
|
183
|
+
if (line === "" || line.startsWith("#") || isShellControlFlow(line))
|
|
184
|
+
continue;
|
|
185
|
+
if (!seen.has(line))
|
|
186
|
+
seen.set(line, workflowName);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const all = [...seen.entries()].map(([command, workflow]) => ({ command, workflow }));
|
|
192
|
+
return {
|
|
193
|
+
commands: all.slice(0, MAX_CI_COMMANDS),
|
|
194
|
+
omittedCount: Math.max(0, all.length - MAX_CI_COMMANDS),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const MAX_DIRS = 20;
|
|
198
|
+
export function extractStructure(signals, lang) {
|
|
199
|
+
const dirNotes = UI[lang].dirNotes;
|
|
200
|
+
const dirs = new Set();
|
|
201
|
+
for (const file of signals.files) {
|
|
202
|
+
const normalized = file.split(path.sep).join("/");
|
|
203
|
+
const slash = normalized.indexOf("/");
|
|
204
|
+
if (slash > 0)
|
|
205
|
+
dirs.add(normalized.slice(0, slash));
|
|
206
|
+
}
|
|
207
|
+
return [...dirs]
|
|
208
|
+
.sort()
|
|
209
|
+
.slice(0, MAX_DIRS)
|
|
210
|
+
.map((dir) => {
|
|
211
|
+
const note = dirNotes[dir.toLowerCase()];
|
|
212
|
+
return note ? { dir: `${dir}/`, note } : { dir: `${dir}/` };
|
|
213
|
+
});
|
|
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
|
+
}
|
|
396
|
+
export function buildRepoFacts(signals, lang) {
|
|
397
|
+
const allCommands = [
|
|
398
|
+
...extractJsPackageCommands(signals),
|
|
399
|
+
...extractComposerCommands(signals),
|
|
400
|
+
...extractMakeTargets(signals),
|
|
401
|
+
...extractMixAliases(signals),
|
|
402
|
+
...extractToxEnvs(signals),
|
|
403
|
+
];
|
|
404
|
+
const { kept, omitted } = filterCommands(allCommands);
|
|
405
|
+
const { commands: ciCommands, omittedCount: omittedCiCount } = extractCiCommands(signals);
|
|
406
|
+
const testDirs = detectTestDirs(signals.files.filter((file) => !isAuxiliaryFactPath(file)));
|
|
407
|
+
const entrypoints = detectEntrypoints(signals);
|
|
408
|
+
return {
|
|
409
|
+
commands: kept,
|
|
410
|
+
omittedCommands: omitted,
|
|
411
|
+
structure: extractStructure(signals, lang),
|
|
412
|
+
ciCommands,
|
|
413
|
+
omittedCiCount,
|
|
414
|
+
canonical: selectCanonicalCommands(signals, kept, ciCommands),
|
|
415
|
+
testDirs,
|
|
416
|
+
entrypoints,
|
|
417
|
+
architectureFacts: extractArchitectureFacts(signals, lang, testDirs, entrypoints),
|
|
418
|
+
conventionFacts: extractConventionFacts(signals, lang),
|
|
419
|
+
};
|
|
420
|
+
}
|