agent-rules-init 0.3.0 → 0.5.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.
@@ -1,18 +1,41 @@
1
1
  import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
2
4
  import { UI } from "./i18n.js";
3
5
  const VERSION_ARGS = {
4
6
  claude: ["--version"],
5
7
  codex: ["--version"],
6
8
  };
7
- export const defaultExecFn = (command, args, stdin) => new Promise((resolve, reject) => {
9
+ // Non-interactive invocation per assistant. claude reads the prompt from stdin with -p;
10
+ // codex has no -p: its non-interactive mode is `codex exec`, where "-" reads the
11
+ // instructions from stdin. Without --skip-git-repo-check codex refuses to run in
12
+ // directories that aren't a git repo; enrichment is read-only, so skipping is safe
13
+ // (verified against codex-cli 0.130.0). The model string is passed through untouched —
14
+ // the assistant validates it, so new models need no package update.
15
+ function printArgs(assistant, model) {
16
+ const modelArgs = model ? ["--model", model] : [];
17
+ if (assistant === "claude")
18
+ return ["-p", ...modelArgs];
19
+ return ["exec", "--skip-git-repo-check", ...modelArgs, "-"];
20
+ }
21
+ // A hung assistant (expired session, dead network) must not hang the CLI forever;
22
+ // 10 minutes comfortably covers real enrichment runs, and on expiry the rejection
23
+ // lands in the normal fallback path that keeps the deterministic files.
24
+ const EXEC_TIMEOUT_MS = 600_000;
25
+ export const defaultExecFn = (command, args, stdin, cwd) => new Promise((resolve, reject) => {
8
26
  // shell:true is only needed on Windows, to resolve npm-installed .cmd/.ps1 shims.
9
- const child = spawn(command, args, { shell: process.platform === "win32" });
27
+ // cwd matters for enrichment: the assistant explores the repo it runs in with its
28
+ // own read tools, so it must be spawned at the target repo root, not wherever the
29
+ // CLI process happens to live.
30
+ const child = spawn(command, args, { shell: process.platform === "win32", cwd, timeout: EXEC_TIMEOUT_MS });
10
31
  let stdout = "";
11
32
  child.stdout?.on("data", (chunk) => (stdout += chunk.toString()));
12
33
  child.on("error", reject);
13
34
  child.on("close", (exitCode) => {
14
35
  if (exitCode === 0)
15
36
  resolve({ stdout, exitCode });
37
+ else if (child.killed)
38
+ reject(new Error(`${command} timed out after ${EXEC_TIMEOUT_MS / 1000}s`));
16
39
  else
17
40
  reject(new Error(`${command} exited with code ${exitCode}`));
18
41
  });
@@ -35,13 +58,142 @@ export async function detectAvailableAssistants(execFn = defaultExecFn) {
35
58
  }));
36
59
  return results.filter((id) => id !== null);
37
60
  }
38
- export async function polishWithAssistant(assistant, content, execFn = defaultExecFn, lang = "es") {
39
- try {
40
- const result = await execFn(assistant, ["-p"], UI[lang].polishPrompt(content));
41
- return result.stdout.trim() || content;
61
+ const MAX_BATCH_CHARS = 60_000;
62
+ function makeBatches(files) {
63
+ const batches = [];
64
+ let current = [];
65
+ let currentSize = 2;
66
+ for (const file of files) {
67
+ const size = JSON.stringify(file).length + 1;
68
+ if (current.length > 0 && currentSize + size > MAX_BATCH_CHARS) {
69
+ batches.push(current);
70
+ current = [];
71
+ currentSize = 2;
72
+ }
73
+ current.push(file);
74
+ currentSize += size;
75
+ }
76
+ if (current.length > 0)
77
+ batches.push(current);
78
+ return batches;
79
+ }
80
+ // Despite being told to return only JSON, assistants often prepend prose ("I investigated
81
+ // the repo and...") or wrap the array in a fence. Try the strict forms first and fall back
82
+ // to the outermost [...] slice; path/count validation below rejects any wrong pick.
83
+ function extractJsonArray(stdout) {
84
+ const trimmed = stdout.trim();
85
+ if (trimmed.startsWith("["))
86
+ return trimmed;
87
+ const fenced = trimmed.match(/```(?:json)?\s*([\s\S]*?)\s*```/i)?.[1];
88
+ if (fenced?.trim().startsWith("["))
89
+ return fenced;
90
+ const start = trimmed.indexOf("[");
91
+ const end = trimmed.lastIndexOf("]");
92
+ if (start !== -1 && end > start)
93
+ return trimmed.slice(start, end + 1);
94
+ return trimmed;
95
+ }
96
+ function parseAssistantBatch(stdout, originals) {
97
+ const parsed = JSON.parse(extractJsonArray(stdout));
98
+ if (!Array.isArray(parsed) || parsed.length !== originals.length) {
99
+ throw new Error("assistant returned a different number of files");
42
100
  }
43
- catch (err) {
44
- console.warn(UI[lang].polishFailed(assistant, err.message));
45
- return content;
101
+ return parsed.map((value, index) => {
102
+ if (!value || typeof value !== "object")
103
+ throw new Error("assistant returned an invalid file entry");
104
+ const entry = value;
105
+ if (entry.path !== originals[index].path || typeof entry.content !== "string") {
106
+ throw new Error("assistant changed a path or returned invalid content");
107
+ }
108
+ return { path: originals[index].path, content: entry.content };
109
+ });
110
+ }
111
+ // The assistant is free-form: it may drop the one command CI actually runs and invent a
112
+ // plausible replacement. Any lost must-keep command invalidates the whole batch.
113
+ function assertMustKeepSurvive(enriched, originals, mustKeep) {
114
+ const originalText = originals.map((f) => f.content).join("\n");
115
+ const enrichedText = enriched.map((f) => f.content).join("\n");
116
+ for (const command of mustKeep) {
117
+ if (originalText.includes(command) && !enrichedText.includes(command)) {
118
+ throw new Error(`assistant dropped a canonical command: ${command}`);
119
+ }
120
+ }
121
+ }
122
+ const EVIDENCE_GROUP = /\((?:evidencia|evidence):([^)]*)\)/gi;
123
+ // A checkable citation is a plain relative path; tokens with spaces, URLs or globs are
124
+ // left alone rather than guessed at.
125
+ const PATH_TOKEN = /^[\w.@~-]+(?:[\\/][\w.@~-]+)*[\\/]?$/;
126
+ function checkableCitedPaths(line) {
127
+ const paths = [];
128
+ for (const group of line.matchAll(EVIDENCE_GROUP)) {
129
+ for (const token of group[1].matchAll(/`([^`]+)`/g)) {
130
+ // Normalize `src/app.py:12` and `pyproject.toml [tool.ruff]` down to the file path.
131
+ const normalized = token[1].replace(/:\d+(?:-\d+)?$/, "").replace(/\s*\[[^\]]*\]$/, "").trim();
132
+ if (PATH_TOKEN.test(normalized))
133
+ paths.push(normalized);
134
+ }
135
+ }
136
+ return paths;
137
+ }
138
+ // Cited evidence is the enrichment's core promise, so it gets checked against the repo:
139
+ // a claim whose cited files all fail to exist is unverifiable. Bullet claims are dropped;
140
+ // prose lines are kept (removing them would mangle paragraphs) but still reported.
141
+ function dropUnverifiableClaims(files, rootPath) {
142
+ const missing = new Set();
143
+ const checked = files.map((file) => {
144
+ const kept = file.content.split("\n").filter((line) => {
145
+ const cited = checkableCitedPaths(line);
146
+ if (cited.length === 0)
147
+ return true;
148
+ if (cited.some((p) => fs.existsSync(path.join(rootPath, p))))
149
+ return true;
150
+ for (const p of cited)
151
+ missing.add(p);
152
+ return !/^\s*[-*] /.test(line);
153
+ });
154
+ return { path: file.path, content: kept.join("\n") };
155
+ });
156
+ return { files: checked, missing: [...missing] };
157
+ }
158
+ /**
159
+ * Asks the assistant to investigate the repository it runs in and rewrite the generated
160
+ * files with repo-specific, evidence-backed guidance. Typical runs use one assistant
161
+ * process; only very large outputs are split. Falls back to the originals per batch.
162
+ */
163
+ export async function enrichFilesWithAssistant(assistant, files, options = {}) {
164
+ const { execFn = defaultExecFn, lang = "es", cwd, mustKeep = [], existingDocs = [], model } = options;
165
+ const existingDocsJson = existingDocs.length > 0 ? JSON.stringify(existingDocs) : undefined;
166
+ const enriched = [];
167
+ for (const batch of makeBatches(files)) {
168
+ const input = UI[lang].enrichPrompt(JSON.stringify(batch), mustKeep, existingDocsJson);
169
+ // Contract violations (invalid JSON, changed paths, dropped commands) are stochastic,
170
+ // especially with smaller models; one bounded retry recovers most of them.
171
+ let attemptsLeft = 2;
172
+ while (attemptsLeft > 0) {
173
+ attemptsLeft--;
174
+ try {
175
+ const result = await execFn(assistant, printArgs(assistant, model), input, cwd);
176
+ let parsed = parseAssistantBatch(result.stdout, batch);
177
+ assertMustKeepSurvive(parsed, batch, mustKeep);
178
+ if (cwd) {
179
+ const verified = dropUnverifiableClaims(parsed, cwd);
180
+ if (verified.missing.length > 0)
181
+ console.warn(UI[lang].enrichEvidenceDropped(verified.missing));
182
+ parsed = verified.files;
183
+ }
184
+ enriched.push(...parsed);
185
+ break;
186
+ }
187
+ catch (err) {
188
+ if (attemptsLeft > 0) {
189
+ console.warn(UI[lang].enrichRetrying(assistant));
190
+ }
191
+ else {
192
+ console.warn(UI[lang].enrichFailed(assistant, err.message));
193
+ enriched.push(...batch);
194
+ }
195
+ }
196
+ }
46
197
  }
198
+ return enriched;
47
199
  }
@@ -0,0 +1,13 @@
1
+ import type { Lang } from "./i18n.js";
2
+ import type { ProjectUnit } from "./project-units.js";
3
+ export interface ProjectOverrides {
4
+ framework?: string;
5
+ testRunner?: string;
6
+ linter?: string;
7
+ packageManager?: string;
8
+ }
9
+ /** Generates the path-scoped AGENTS file for one JS/TS package. */
10
+ export declare function renderProjectUnitAgents(unit: ProjectUnit, lang: Lang, overrides?: ProjectOverrides): {
11
+ path: string;
12
+ content: string;
13
+ } | null;
@@ -0,0 +1,25 @@
1
+ import { buildRepoFacts } from "./repo-facts.js";
2
+ import { renderAgentsMd } from "./templates.js";
3
+ import { jsTsPack } from "../packs/js-ts.js";
4
+ function applyOverrides(detection, overrides) {
5
+ const updated = { ...detection };
6
+ for (const field of ["framework", "testRunner", "linter", "packageManager"]) {
7
+ const value = overrides[field];
8
+ if (value)
9
+ updated[field] = { value, confidence: "high" };
10
+ }
11
+ return updated;
12
+ }
13
+ /** Generates the path-scoped AGENTS file for one JS/TS package. */
14
+ export function renderProjectUnitAgents(unit, lang, overrides = {}) {
15
+ const rawDetection = jsTsPack.detect(unit.signals);
16
+ if (!rawDetection)
17
+ return null;
18
+ const detection = applyOverrides(rawDetection, overrides);
19
+ const facts = buildRepoFacts(unit.signals, lang);
20
+ const ruleSet = jsTsPack.rules(detection, lang, { facts, signals: unit.signals });
21
+ return {
22
+ path: `${unit.path}/AGENTS.generated.md`,
23
+ content: renderAgentsMd([{ detection, ruleSet }], facts, lang),
24
+ };
25
+ }
@@ -0,0 +1,16 @@
1
+ import type { RepoSignals } from "./types.js";
2
+ export interface ProjectUnit {
3
+ /** POSIX path relative to the repository root. */
4
+ path: string;
5
+ signals: RepoSignals;
6
+ }
7
+ export declare function isProjectExcluded(projectPath: string, patterns: readonly string[]): boolean;
8
+ /** Removes excluded JS packages before repo-wide aggregation and scoped rendering. */
9
+ export declare function applyProjectExcludes(signals: RepoSignals, patterns: readonly string[]): RepoSignals;
10
+ /**
11
+ * Creates an isolated RepoSignals view for every nested JS/TS package.
12
+ *
13
+ * Files and presence checks become relative to the package, so packs can run without
14
+ * learning workspace-specific rules and cannot accidentally use a sibling's stack.
15
+ */
16
+ export declare function buildPackageUnits(signals: RepoSignals): ProjectUnit[];
@@ -0,0 +1,95 @@
1
+ import path from "node:path";
2
+ function normalize(value) {
3
+ return value.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/$/, "");
4
+ }
5
+ function matchesPattern(projectPath, rawPattern) {
6
+ const value = normalize(projectPath);
7
+ const pattern = normalize(rawPattern);
8
+ if (!pattern)
9
+ return false;
10
+ if (pattern.endsWith("/**") && value === pattern.slice(0, -3))
11
+ return true;
12
+ const marker = "\u0000";
13
+ const regex = pattern
14
+ .replace(/\*\*/g, marker)
15
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
16
+ .replace(/\*/g, "[^/]*")
17
+ .replace(new RegExp(marker, "g"), ".*");
18
+ return new RegExp(`^${regex}$`).test(value);
19
+ }
20
+ export function isProjectExcluded(projectPath, patterns) {
21
+ return patterns.some((pattern) => matchesPattern(projectPath, pattern));
22
+ }
23
+ /** Removes excluded JS packages before repo-wide aggregation and scoped rendering. */
24
+ export function applyProjectExcludes(signals, patterns) {
25
+ if (patterns.length === 0 || !signals.packageJsons?.length)
26
+ return signals;
27
+ const packageJsons = signals.packageJsons.filter((manifest) => {
28
+ const packageDir = path.posix.dirname(manifest.path);
29
+ return packageDir === "." || !isProjectExcluded(packageDir, patterns);
30
+ });
31
+ const excludedDirs = (signals.packageJsons ?? [])
32
+ .map((manifest) => path.posix.dirname(manifest.path))
33
+ .filter((dir) => dir !== "." && isProjectExcluded(dir, patterns));
34
+ const isExcludedPath = (relativePath) => {
35
+ const normalized = normalize(relativePath);
36
+ return excludedDirs.some((dir) => normalized === dir || normalized.startsWith(`${dir}/`));
37
+ };
38
+ const primary = packageJsons[0];
39
+ const packageJson = primary
40
+ ? {
41
+ name: primary.name,
42
+ main: primary.main,
43
+ dependencies: Object.assign({}, ...packageJsons.map((manifest) => manifest.dependencies)),
44
+ devDependencies: Object.assign({}, ...packageJsons.map((manifest) => manifest.devDependencies)),
45
+ scripts: primary.scripts,
46
+ moduleType: primary.moduleType,
47
+ packageManager: primary.packageManager,
48
+ }
49
+ : undefined;
50
+ return {
51
+ ...signals,
52
+ files: signals.files.filter((file) => !isExcludedPath(file)),
53
+ hasFile: (relativePath) => !isExcludedPath(relativePath) && signals.hasFile(relativePath),
54
+ hasDir: (relativeDir) => !isExcludedPath(relativeDir) && signals.hasDir(relativeDir),
55
+ packageJson,
56
+ packageJsons,
57
+ };
58
+ }
59
+ function withoutLocation(manifest) {
60
+ const { path: _path, ...packageJson } = manifest;
61
+ return packageJson;
62
+ }
63
+ /**
64
+ * Creates an isolated RepoSignals view for every nested JS/TS package.
65
+ *
66
+ * Files and presence checks become relative to the package, so packs can run without
67
+ * learning workspace-specific rules and cannot accidentally use a sibling's stack.
68
+ */
69
+ export function buildPackageUnits(signals) {
70
+ return (signals.packageJsons ?? [])
71
+ .filter((manifest) => manifest.path !== "package.json")
72
+ .map((manifest) => {
73
+ const unitPath = path.posix.dirname(manifest.path);
74
+ const prefix = `${unitPath}/`;
75
+ const unitFiles = signals.files
76
+ .map((file) => file.split(path.sep).join("/"))
77
+ .filter((file) => file.startsWith(prefix))
78
+ .map((file) => file.slice(prefix.length));
79
+ const packageJson = withoutLocation(manifest);
80
+ return {
81
+ path: unitPath,
82
+ signals: {
83
+ rootPath: path.join(signals.rootPath, ...unitPath.split("/")),
84
+ files: unitFiles,
85
+ hasFile: (relativePath) => signals.hasFile(`${prefix}${relativePath}`),
86
+ hasDir: (relativeDir) => signals.hasDir(`${prefix}${relativeDir}`),
87
+ packageJson,
88
+ packageJsons: [{ ...packageJson, path: "package.json" }],
89
+ guidanceFiles: (signals.guidanceFiles ?? [])
90
+ .filter((file) => file.path.startsWith(prefix))
91
+ .map((file) => ({ ...file, path: file.path.slice(prefix.length) })),
92
+ },
93
+ };
94
+ });
95
+ }
@@ -1,6 +1,6 @@
1
1
  import { type Lang } from "./i18n.js";
2
- import type { CiCommand, CommandEntry, CommandSource, DirEntry, RepoFacts, RepoSignals } from "./types.js";
3
- export declare function extractNpmCommands(signals: RepoSignals): CommandEntry[];
2
+ import type { CiCommand, ArchitectureFact, CommandEntry, CommandSource, ConventionFact, DirEntry, RepoFacts, RepoSignals } from "./types.js";
3
+ export declare function extractJsPackageCommands(signals: RepoSignals): CommandEntry[];
4
4
  export declare function extractMakeTargets(signals: RepoSignals): CommandEntry[];
5
5
  export declare function extractMixAliases(signals: RepoSignals): CommandEntry[];
6
6
  export declare function extractToxEnvs(signals: RepoSignals): CommandEntry[];
@@ -17,4 +17,12 @@ export declare function extractCiCommands(signals: RepoSignals): {
17
17
  omittedCount: number;
18
18
  };
19
19
  export declare function extractStructure(signals: RepoSignals, lang: Lang): DirEntry[];
20
+ export declare function detectTestDirs(files: string[]): string[];
21
+ export declare function detectEntrypoints(signals: RepoSignals): RepoFacts["entrypoints"];
22
+ export declare function extractArchitectureFacts(signals: RepoSignals, lang: Lang, testDirs?: string[], entrypoints?: {
23
+ label: string;
24
+ target: string;
25
+ source: string;
26
+ }[]): ArchitectureFact[];
27
+ export declare function extractConventionFacts(signals: RepoSignals, lang: Lang): ConventionFact[];
20
28
  export declare function buildRepoFacts(signals: RepoSignals, lang: Lang): RepoFacts;
@@ -1,21 +1,53 @@
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 extractNpmCommands(signals) {
6
- const scripts = signals.packageJson?.scripts ?? {};
6
+ export function extractJsPackageCommands(signals) {
7
7
  const entries = [];
8
- for (const [name, body] of Object.entries(scripts)) {
9
- if (typeof body !== "string" || body.trim() === "")
10
- continue;
11
- entries.push({
12
- source: "npm",
13
- invocation: NPM_DIRECT_LIFECYCLE.has(name) ? `npm ${name}` : `npm run ${name}`,
14
- detail: body.trim(),
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
+ function jsScriptInvocation(manager, packageDir, script) {
32
+ const name = quoteShellArg(script);
33
+ if (manager === "npm") {
34
+ const prefix = packageDir === "." ? "npm" : `npm --prefix ${quoteShellArg(packageDir)}`;
35
+ return NPM_DIRECT_LIFECYCLE.has(script) ? `${prefix} ${name}` : `${prefix} run ${name}`;
36
+ }
37
+ if (manager === "pnpm") {
38
+ const prefix = packageDir === "." ? "pnpm" : `pnpm --dir ${quoteShellArg(packageDir)}`;
39
+ return NPM_DIRECT_LIFECYCLE.has(script) ? `${prefix} ${name}` : `${prefix} run ${name}`;
40
+ }
41
+ if (manager === "yarn") {
42
+ const prefix = packageDir === "." ? "yarn" : `yarn --cwd ${quoteShellArg(packageDir)}`;
43
+ return `${prefix} run ${name}`;
44
+ }
45
+ const prefix = packageDir === "." ? "bun" : `bun --cwd ${quoteShellArg(packageDir)}`;
46
+ return `${prefix} run ${name}`;
47
+ }
48
+ function quoteShellArg(value) {
49
+ return /\s/.test(value) ? JSON.stringify(value) : value;
50
+ }
19
51
  export function extractMakeTargets(signals) {
20
52
  const makefile = signals.makefile;
21
53
  if (!makefile)
@@ -178,9 +210,190 @@ export function extractStructure(signals, lang) {
178
210
  return note ? { dir: `${dir}/`, note } : { dir: `${dir}/` };
179
211
  });
180
212
  }
213
+ const TEST_DIR_NAMES = new Set(["test", "tests", "spec", "specs", "__tests__"]);
214
+ const MEANINGFUL_TEST_CHILDREN = new Set(["acceptance", "integration", "unit", "e2e"]);
215
+ const AUXILIARY_FACT_DIRS = new Set([
216
+ "docs", "doc", "tools", "tool", "scripts", "script", "examples", "example",
217
+ "benchmark", "benchmarks", "fixture", "fixtures",
218
+ ]);
219
+ function isAuxiliaryFactPath(file) {
220
+ return file.split(/[\\/]/).some((segment) => AUXILIARY_FACT_DIRS.has(segment.toLowerCase()));
221
+ }
222
+ export function detectTestDirs(files) {
223
+ const dirs = new Set();
224
+ for (const file of files) {
225
+ const segments = file.split(/[\\/]/).slice(0, -1);
226
+ for (let i = 0; i < segments.length; i++) {
227
+ if (TEST_DIR_NAMES.has(segments[i].toLowerCase())) {
228
+ dirs.add(segments.slice(0, i + 1).join("/") + "/");
229
+ // Solo conserva sublayouts inequívocos. Directorios como tests/static o
230
+ // tests/templates suelen ser fixtures, no ubicaciones independientes de tests.
231
+ const child = segments[i + 1]?.toLowerCase();
232
+ const languageLayout = segments[0]?.toLowerCase() === "src" && segments[1]?.toLowerCase() === "test";
233
+ if (child && (languageLayout || MEANINGFUL_TEST_CHILDREN.has(child))) {
234
+ dirs.add(segments.slice(0, i + 2).join("/") + "/");
235
+ }
236
+ break;
237
+ }
238
+ }
239
+ }
240
+ return [...dirs].sort();
241
+ }
242
+ export function detectEntrypoints(signals) {
243
+ const out = [];
244
+ if (signals.packageJson?.main) {
245
+ out.push({ label: "main", target: signals.packageJson.main, source: "package.json" });
246
+ }
247
+ const scriptsSection = signals.pyprojectToml?.match(/\[project\.scripts\]([\s\S]*?)(?:\n\[|$)/)?.[1];
248
+ if (scriptsSection) {
249
+ for (const match of scriptsSection.matchAll(/^\s*([\w.-]+)\s*=\s*["']([^"']+)["']/gm)) {
250
+ out.push({ label: match[1], target: match[2], source: "pyproject.toml" });
251
+ }
252
+ }
253
+ return out;
254
+ }
255
+ function evidencePath(file) {
256
+ return file.split(path.sep).join("/");
257
+ }
258
+ export function extractArchitectureFacts(signals, lang, testDirs = detectTestDirs(signals.files.filter((file) => !isAuxiliaryFactPath(file))), entrypoints = detectEntrypoints(signals)) {
259
+ const facts = [];
260
+ const normalized = signals.files.filter((file) => !isAuxiliaryFactPath(file)).map(evidencePath);
261
+ const add = (fact) => facts.push({ ...fact, scope: ".", confidence: "high" });
262
+ if (entrypoints.length > 0) {
263
+ const rendered = entrypoints.map((entry) => `${entry.label} -> ${entry.target}`).join(", ");
264
+ add({
265
+ kind: "entrypoint",
266
+ statement: lang === "es" ? `Puntos de entrada declarados: ${rendered}.` : `Declared entry points: ${rendered}.`,
267
+ evidence: [...new Set(entrypoints.map((entry) => entry.source))],
268
+ });
269
+ }
270
+ if (testDirs.length > 0) {
271
+ const testEvidence = [...new Set(testDirs.map((dir) => {
272
+ const directory = dir.replace(/\/$/, "");
273
+ return signals.hasDir(directory) ? dir : normalized.find((file) => file.startsWith(dir));
274
+ }).filter((f) => Boolean(f)))];
275
+ add({
276
+ kind: "tests",
277
+ statement: lang === "es"
278
+ ? `Los tests se colocan en ${testDirs.join(", ")}.`
279
+ : `Tests are placed under ${testDirs.join(", ")}.`,
280
+ evidence: testEvidence,
281
+ });
282
+ }
283
+ const sourceRoots = ["src", "lib"].filter((dir) => signals.hasDir(dir) || normalized.some((file) => file.startsWith(`${dir}/`)));
284
+ const sourceLocations = sourceRoots.flatMap((dir) => dir === "src" && signals.hasDir("src/main") ? ["src/main/"] : [`${dir}/`]);
285
+ if (sourceLocations.length > 0) {
286
+ add({
287
+ kind: "source-layout",
288
+ statement: lang === "es"
289
+ ? `El código principal vive en ${sourceLocations.join(", ")}.`
290
+ : `Primary source code lives under ${sourceLocations.join(", ")}.`,
291
+ evidence: sourceLocations.map((location) => {
292
+ const directory = location.replace(/\/$/, "");
293
+ return signals.hasDir(directory) ? location : normalized.find((file) => file.startsWith(location));
294
+ }).filter(Boolean),
295
+ });
296
+ }
297
+ const layerPatterns = [
298
+ ["controller", /(?:^|\/)(?:controllers?)(?:\/|$)/i],
299
+ ["service", /(?:^|\/)(?:services?)(?:\/|$)/i],
300
+ ["repository", /(?:^|\/)(?:repositories|repository)(?:\/|$)/i],
301
+ ];
302
+ const layers = layerPatterns.map(([name, pattern]) => ({ name, file: normalized.find((file) => pattern.test(file)) }));
303
+ if (layers.every((layer) => layer.file)) {
304
+ add({
305
+ kind: "layers",
306
+ statement: lang === "es"
307
+ ? "El repositorio separa controller, service y repository en capas distintas."
308
+ : "The repository separates controller, service and repository into distinct layers.",
309
+ evidence: layers.map((layer) => layer.file),
310
+ });
311
+ }
312
+ const workspaceManifests = (signals.packageJsons ?? []).filter((manifest) => manifest.path !== "package.json");
313
+ if (workspaceManifests.length > 0) {
314
+ add({
315
+ kind: "workspace",
316
+ statement: lang === "es"
317
+ ? `El workspace contiene ${workspaceManifests.length} paquete(s) JavaScript/TypeScript anidado(s).`
318
+ : `The workspace contains ${workspaceManifests.length} nested JavaScript/TypeScript package(s).`,
319
+ evidence: workspaceManifests.map((manifest) => manifest.path),
320
+ });
321
+ }
322
+ return facts;
323
+ }
324
+ function uniqueConfigValue(content, key) {
325
+ const values = [...content.matchAll(new RegExp(`^\\s*${key}\\s*=\\s*([^#;\\r\\n]+)`, "gmi"))]
326
+ .map((match) => match[1].trim().toLowerCase());
327
+ const unique = [...new Set(values)];
328
+ return unique.length === 1 ? unique[0] : undefined;
329
+ }
330
+ export function extractConventionFacts(signals, lang) {
331
+ const facts = [];
332
+ const rootFiles = (signals.guidanceFiles ?? []).filter((file) => !evidencePath(file.path).includes("/"));
333
+ const get = (name) => rootFiles.find((file) => file.path.toLowerCase() === name.toLowerCase())?.content;
334
+ const add = (fact) => facts.push({ ...fact, scope: ".", confidence: "high" });
335
+ const editorConfig = get(".editorconfig");
336
+ if (editorConfig) {
337
+ const style = uniqueConfigValue(editorConfig, "indent_style");
338
+ const rawSize = uniqueConfigValue(editorConfig, "indent_size");
339
+ const size = rawSize && /^\d+$/.test(rawSize) ? rawSize : undefined;
340
+ if (style)
341
+ add({
342
+ kind: "formatting",
343
+ statement: lang === "es"
344
+ ? `La indentación usa ${style === "space" ? "espacios" : style === "tab" ? "tabuladores" : style}${size ? ` con tamaño ${size}` : ""}.`
345
+ : `Indentation uses ${style === "space" ? "spaces" : style === "tab" ? "tabs" : style}${size ? ` with size ${size}` : ""}.`,
346
+ evidence: [".editorconfig"],
347
+ });
348
+ const finalNewline = uniqueConfigValue(editorConfig, "insert_final_newline");
349
+ if (finalNewline === "true")
350
+ add({
351
+ kind: "formatting",
352
+ statement: lang === "es" ? "Los archivos deben terminar con una línea nueva." : "Files must end with a final newline.",
353
+ evidence: [".editorconfig"],
354
+ });
355
+ }
356
+ const tsconfig = get("tsconfig.json");
357
+ if (tsconfig && /["']strict["']\s*:\s*true\b/i.test(tsconfig))
358
+ add({
359
+ kind: "typescript",
360
+ statement: lang === "es" ? "TypeScript tiene activado el modo strict." : "TypeScript strict mode is enabled.",
361
+ evidence: ["tsconfig.json"],
362
+ });
363
+ const pyproject = get("pyproject.toml");
364
+ if (pyproject) {
365
+ for (const tool of ["ruff", "black"]) {
366
+ const section = pyproject.match(new RegExp(`(?:^|\\n)\\[tool\\.${tool}\\]([\\s\\S]*?)(?:\\n\\[|$)`, "i"))?.[1];
367
+ const lineLength = section?.match(/(?:^|\n)\s*line-length\s*=\s*(\d+)/i)?.[1];
368
+ if (lineLength)
369
+ add({
370
+ kind: "python-style",
371
+ statement: lang === "es"
372
+ ? `${tool} configura una longitud máxima de línea de ${lineLength}.`
373
+ : `${tool} configures a maximum line length of ${lineLength}.`,
374
+ evidence: ["pyproject.toml"],
375
+ });
376
+ }
377
+ }
378
+ const contributing = get("CONTRIBUTING.md");
379
+ if (contributing) {
380
+ const directives = contributing.split(/\r?\n/)
381
+ .map((line) => line.match(/^\s*[-*]\s+(.{1,180})$/)?.[1]?.trim())
382
+ .filter((line) => Boolean(line))
383
+ .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))
384
+ .slice(0, 5);
385
+ for (const directive of directives)
386
+ add({
387
+ kind: "contributing",
388
+ statement: directive,
389
+ evidence: ["CONTRIBUTING.md"],
390
+ });
391
+ }
392
+ return facts;
393
+ }
181
394
  export function buildRepoFacts(signals, lang) {
182
395
  const allCommands = [
183
- ...extractNpmCommands(signals),
396
+ ...extractJsPackageCommands(signals),
184
397
  ...extractComposerCommands(signals),
185
398
  ...extractMakeTargets(signals),
186
399
  ...extractMixAliases(signals),
@@ -188,11 +401,18 @@ export function buildRepoFacts(signals, lang) {
188
401
  ];
189
402
  const { kept, omitted } = filterCommands(allCommands);
190
403
  const { commands: ciCommands, omittedCount: omittedCiCount } = extractCiCommands(signals);
404
+ const testDirs = detectTestDirs(signals.files.filter((file) => !isAuxiliaryFactPath(file)));
405
+ const entrypoints = detectEntrypoints(signals);
191
406
  return {
192
407
  commands: kept,
193
408
  omittedCommands: omitted,
194
409
  structure: extractStructure(signals, lang),
195
410
  ciCommands,
196
411
  omittedCiCount,
412
+ canonical: selectCanonicalCommands(signals, kept, ciCommands),
413
+ testDirs,
414
+ entrypoints,
415
+ architectureFacts: extractArchitectureFacts(signals, lang, testDirs, entrypoints),
416
+ conventionFacts: extractConventionFacts(signals, lang),
197
417
  };
198
418
  }