agent-rules-init 0.2.0 → 0.2.1

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.
@@ -3,7 +3,7 @@ export interface ExecResult {
3
3
  stdout: string;
4
4
  exitCode: number;
5
5
  }
6
- export type ExecFn = (command: string, args: string[]) => Promise<ExecResult>;
6
+ export type ExecFn = (command: string, args: string[], stdin?: string) => Promise<ExecResult>;
7
7
  export declare const defaultExecFn: ExecFn;
8
8
  export declare function detectAvailableAssistants(execFn?: ExecFn): Promise<AssistantId[]>;
9
9
  export declare function polishWithAssistant(assistant: AssistantId, content: string, execFn?: ExecFn): Promise<string>;
@@ -3,11 +3,8 @@ const VERSION_ARGS = {
3
3
  claude: ["--version"],
4
4
  codex: ["--version"],
5
5
  };
6
- export const defaultExecFn = (command, args) => new Promise((resolve, reject) => {
6
+ export const defaultExecFn = (command, args, stdin) => new Promise((resolve, reject) => {
7
7
  // shell:true is only needed on Windows, to resolve npm-installed .cmd/.ps1 shims.
8
- // On POSIX, spawn(command, args) execs the binary directly with argv, so passing
9
- // large generated content as an argument (see polishWithAssistant) can never be
10
- // reinterpreted by a shell there.
11
8
  const child = spawn(command, args, { shell: process.platform === "win32" });
12
9
  let stdout = "";
13
10
  child.stdout?.on("data", (chunk) => (stdout += chunk.toString()));
@@ -18,6 +15,11 @@ export const defaultExecFn = (command, args) => new Promise((resolve, reject) =>
18
15
  else
19
16
  reject(new Error(`${command} exited with code ${exitCode}`));
20
17
  });
18
+ // Content always goes through stdin, never as a CLI argument: on Windows, spawn's
19
+ // shell:true routes the command through cmd.exe, which cannot reliably carry a
20
+ // multi-line, multi-KB argument (embedded newlines truncate/corrupt it). Piping
21
+ // via stdin has no such limit and needs no shell-quoting at all.
22
+ child.stdin?.end(stdin ?? "");
21
23
  });
22
24
  export async function detectAvailableAssistants(execFn = defaultExecFn) {
23
25
  const candidates = ["claude", "codex"];
@@ -33,9 +35,9 @@ export async function detectAvailableAssistants(execFn = defaultExecFn) {
33
35
  return results.filter((id) => id !== null);
34
36
  }
35
37
  export async function polishWithAssistant(assistant, content, execFn = defaultExecFn) {
36
- const prompt = `Pule la redacción del siguiente documento de instrucciones para un agente de IA, sin cambiar su significado ni estructura:\n\n${content}`;
38
+ const prompt = `Pule la redacción del siguiente documento de instrucciones para un agente de IA, sin cambiar su significado ni estructura. Devuelve únicamente el documento pulido, sin comentarios ni explicaciones adicionales:\n\n${content}`;
37
39
  try {
38
- const result = await execFn(assistant, ["-p", prompt]);
40
+ const result = await execFn(assistant, ["-p"], prompt);
39
41
  return result.stdout.trim() || content;
40
42
  }
41
43
  catch (err) {
@@ -56,8 +56,43 @@ function shallowest(matches) {
56
56
  function findFirst(files, fileName) {
57
57
  return shallowest(files.filter((f) => path.basename(f) === fileName));
58
58
  }
59
- function findFirstByExtension(files, extension) {
60
- return shallowest(files.filter((f) => f.toLowerCase().endsWith(extension)));
59
+ // Directories that routinely carry their own throwaway Python tooling manifest (a
60
+ // mkdocs/sphinx docs build, a one-off script, a benchmark harness) in projects whose
61
+ // primary language is something else entirely (e.g. nlohmann/json, a C++ library, ships
62
+ // docs/mkdocs/requirements.txt). Prefer a candidate outside these dirs when one exists.
63
+ const NON_PROJECT_DIRS = new Set([
64
+ "docs",
65
+ "doc",
66
+ "tools",
67
+ "tool",
68
+ "scripts",
69
+ "script",
70
+ "examples",
71
+ "example",
72
+ "benchmark",
73
+ "benchmarks",
74
+ ]);
75
+ function isUnderNonProjectDir(relativePath) {
76
+ const segments = relativePath.split(path.sep).slice(0, -1);
77
+ return segments.some((segment) => NON_PROJECT_DIRS.has(segment.toLowerCase()));
78
+ }
79
+ function findFirstPreferringRealProjectDirs(files, fileName) {
80
+ const matches = files.filter((f) => path.basename(f) === fileName && !isUnderNonProjectDir(f));
81
+ return shallowest(matches);
82
+ }
83
+ function findAllByExtension(files, extension) {
84
+ return files.filter((f) => f.toLowerCase().endsWith(extension));
85
+ }
86
+ function findAllByNames(files, names) {
87
+ return files.filter((f) => names.includes(path.basename(f)));
88
+ }
89
+ function readAllConcatenated(rootPath, relativePaths) {
90
+ if (relativePaths.length === 0)
91
+ return undefined;
92
+ const contents = relativePaths
93
+ .map((p) => readTextIfExists(path.join(rootPath, p)))
94
+ .filter((content) => content !== undefined);
95
+ return contents.length > 0 ? contents.join("\n") : undefined;
61
96
  }
62
97
  function pickShallowest(paths) {
63
98
  return shallowest(paths.filter((p) => p !== undefined));
@@ -75,6 +110,7 @@ export function scanRepo(rootPath) {
75
110
  dependencies: rawPackageJson.dependencies ?? {},
76
111
  devDependencies: rawPackageJson.devDependencies ?? {},
77
112
  scripts: rawPackageJson.scripts ?? {},
113
+ moduleType: rawPackageJson.type === "module" ? "module" : "commonjs",
78
114
  }
79
115
  : undefined;
80
116
  const composerJsonPath = findFirst(files, "composer.json");
@@ -87,9 +123,10 @@ export function scanRepo(rootPath) {
87
123
  requireDev: rawComposerJson["require-dev"] ?? {},
88
124
  }
89
125
  : undefined;
90
- const pyprojectCandidate = findFirst(files, "pyproject.toml");
91
- const requirementsCandidate = findFirst(files, "requirements.txt");
92
- const environmentYmlCandidate = findFirst(files, "environment.yml") ?? findFirst(files, "environment.yaml");
126
+ const pyprojectCandidate = findFirstPreferringRealProjectDirs(files, "pyproject.toml");
127
+ const requirementsCandidate = findFirstPreferringRealProjectDirs(files, "requirements.txt");
128
+ const environmentYmlCandidate = findFirstPreferringRealProjectDirs(files, "environment.yml") ??
129
+ findFirstPreferringRealProjectDirs(files, "environment.yaml");
93
130
  // Only the shallowest of the three "wins" as the project's Python manifest — otherwise an
94
131
  // unrelated pyproject.toml nested inside a vendored/data subdirectory (e.g. a bundled dataset
95
132
  // package) would always outrank a real root-level environment.yml just by file type.
@@ -101,14 +138,24 @@ export function scanRepo(rootPath) {
101
138
  const pyprojectPath = primaryPythonManifest === pyprojectCandidate ? pyprojectCandidate : undefined;
102
139
  const requirementsPath = primaryPythonManifest === requirementsCandidate ? requirementsCandidate : undefined;
103
140
  const environmentYmlPath = primaryPythonManifest === environmentYmlCandidate ? environmentYmlCandidate : undefined;
104
- const pomPath = findFirst(files, "pom.xml");
105
- const buildGradlePath = findFirst(files, "build.gradle") ?? findFirst(files, "build.gradle.kts");
141
+ // Multi-module Maven/Gradle projects (parent + child modules) each have their own
142
+ // pom.xml/build.gradle, and the module that actually declares the framework/Kotlin
143
+ // plugin/test runner isn't necessarily the shallowest one. Aggregate all of them.
144
+ const pomPaths = findAllByNames(files, ["pom.xml"]);
145
+ const buildGradlePaths = findAllByNames(files, ["build.gradle", "build.gradle.kts"]);
106
146
  const gemfilePath = findFirst(files, "Gemfile");
107
147
  const goModPath = findFirst(files, "go.mod");
108
148
  const cargoTomlPath = findFirst(files, "Cargo.toml");
109
- const csprojPath = findFirstByExtension(files, ".csproj");
149
+ // .NET solutions routinely split into several .csproj files (domain/infra/web/tests);
150
+ // picking just the "shallowest" one is close to arbitrary and often lands on a plain
151
+ // class library that has neither the web framework nor the test runner reference.
152
+ // Concatenate all of them so detection can find those references wherever they live.
153
+ const csprojPaths = findAllByExtension(files, ".csproj");
110
154
  const packageSwiftPath = findFirst(files, "Package.swift");
111
- const pubspecYamlPath = findFirst(files, "pubspec.yaml");
155
+ // Melos/pub workspaces have a root pubspec.yaml that's just workspace glue (no
156
+ // `flutter`/`flutter_test` dependency) plus one real pubspec.yaml per package under
157
+ // packages/*. Aggregate all of them so the actual framework/test runner is found.
158
+ const pubspecYamlPaths = findAllByNames(files, ["pubspec.yaml"]);
112
159
  const cmakeListsPath = findFirst(files, "CMakeLists.txt");
113
160
  const makefilePath = findFirst(files, "Makefile") ?? findFirst(files, "makefile");
114
161
  const mixExsPath = findFirst(files, "mix.exs");
@@ -129,15 +176,15 @@ export function scanRepo(rootPath) {
129
176
  environmentYml: environmentYmlPath
130
177
  ? readTextIfExists(path.join(rootPath, environmentYmlPath))
131
178
  : undefined,
132
- pomXml: pomPath ? readTextIfExists(path.join(rootPath, pomPath)) : undefined,
133
- buildGradle: buildGradlePath ? readTextIfExists(path.join(rootPath, buildGradlePath)) : undefined,
179
+ pomXml: readAllConcatenated(rootPath, pomPaths),
180
+ buildGradle: readAllConcatenated(rootPath, buildGradlePaths),
134
181
  composerJson,
135
182
  gemfile: gemfilePath ? readTextIfExists(path.join(rootPath, gemfilePath)) : undefined,
136
183
  goMod: goModPath ? readTextIfExists(path.join(rootPath, goModPath)) : undefined,
137
184
  cargoToml: cargoTomlPath ? readTextIfExists(path.join(rootPath, cargoTomlPath)) : undefined,
138
- csproj: csprojPath ? readTextIfExists(path.join(rootPath, csprojPath)) : undefined,
185
+ csproj: readAllConcatenated(rootPath, csprojPaths),
139
186
  packageSwift: packageSwiftPath ? readTextIfExists(path.join(rootPath, packageSwiftPath)) : undefined,
140
- pubspecYaml: pubspecYamlPath ? readTextIfExists(path.join(rootPath, pubspecYamlPath)) : undefined,
187
+ pubspecYaml: readAllConcatenated(rootPath, pubspecYamlPaths),
141
188
  cmakeLists: cmakeListsPath ? readTextIfExists(path.join(rootPath, cmakeListsPath)) : undefined,
142
189
  makefile: makefilePath ? readTextIfExists(path.join(rootPath, makefilePath)) : undefined,
143
190
  mixExs: mixExsPath ? readTextIfExists(path.join(rootPath, mixExsPath)) : undefined,
@@ -3,6 +3,7 @@ export interface PackageJsonManifest {
3
3
  dependencies: Record<string, string>;
4
4
  devDependencies: Record<string, string>;
5
5
  scripts: Record<string, string>;
6
+ moduleType: "module" | "commonjs";
6
7
  }
7
8
  export interface ComposerJsonManifest {
8
9
  require: Record<string, string>;
@@ -45,6 +46,8 @@ export interface DetectionResult {
45
46
  packageManager?: DetectionField<string>;
46
47
  testRunner?: DetectionField<string>;
47
48
  linter?: DetectionField<string>;
49
+ usesTypeScript?: boolean;
50
+ moduleFormat?: "module" | "commonjs";
48
51
  }
49
52
  export interface RuleSet {
50
53
  summary: string;
package/dist/packs/cpp.js CHANGED
@@ -8,8 +8,22 @@ const TEST_RUNNERS = [
8
8
  ["catch2", "catch2"],
9
9
  ["doctest", "doctest"],
10
10
  ];
11
+ const CPP_COMPILER_TOKENS = ["$(cc)", "$(cxx)", "gcc", "g++", "clang"];
12
+ // Source-file extensions need a stricter boundary check than a plain substring:
13
+ // Go module paths hosted on the ".cc" ccTLD (e.g. "mvdan.cc/gofumpt") also contain
14
+ // ".cc", but it's followed by "/", never by whitespace/quote/paren/end-of-line the
15
+ // way a real compiled source file reference in a Makefile would be.
16
+ const CPP_SOURCE_EXTENSION = /\.(cpp|cxx|cc)(?=[\s:"')]|$)/im;
17
+ function looksLikeCppMakefile(makefile) {
18
+ const lower = makefile.toLowerCase();
19
+ return CPP_COMPILER_TOKENS.some((needle) => lower.includes(needle)) || CPP_SOURCE_EXTENSION.test(makefile) || /\.o:/.test(makefile);
20
+ }
11
21
  function detect(signals) {
12
- const source = signals.cmakeLists ?? signals.makefile;
22
+ // A bare Makefile isn't C/C++-specific — plenty of Python/JS/etc. projects ship
23
+ // one as a generic task runner (or a Sphinx docs Makefile, like Flask's docs/Makefile).
24
+ // Only trust it once it actually references a C/C++ compiler or source file.
25
+ const makefileSource = signals.makefile && looksLikeCppMakefile(signals.makefile) ? signals.makefile : undefined;
26
+ const source = signals.cmakeLists ?? makefileSource;
13
27
  if (!source)
14
28
  return null;
15
29
  const lower = source.toLowerCase();
@@ -3,7 +3,11 @@ function detect(signals) {
3
3
  if (!source)
4
4
  return null;
5
5
  const lower = source.toLowerCase();
6
- const framework = lower.includes(":phoenix")
6
+ // A plain `.includes(":phoenix")` also matches sibling hex packages like `:phoenix_pubsub`
7
+ // or `:phoenix_html` (substring prefix), and Phoenix's own mix.exs (`app: :phoenix`) —
8
+ // neither means the project actually depends on the `phoenix` package. Match the
9
+ // `{:phoenix, ...}` dependency-tuple syntax instead, which only the real dependency uses.
10
+ const framework = /\{\s*:phoenix\s*,/.test(lower)
7
11
  ? { value: "phoenix", confidence: "high" }
8
12
  : { value: "none", confidence: "low" };
9
13
  return {
package/dist/packs/go.js CHANGED
@@ -4,11 +4,23 @@ const FRAMEWORKS = [
4
4
  ["gofiber/fiber", "fiber"],
5
5
  ["go-chi/chi", "chi"],
6
6
  ];
7
+ // The `module` line of go.mod is the project's own import path, not a dependency —
8
+ // searching the whole file would false-positive on a framework whose own repo module
9
+ // path happens to match a known framework name (e.g. gofiber/fiber's own go.mod
10
+ // declares `module github.com/gofiber/fiber/v3`). Scope the search to `require` entries.
11
+ function extractGoRequireSection(goMod) {
12
+ const sections = [];
13
+ for (const m of goMod.matchAll(/require\s*\(([\s\S]*?)\)/g))
14
+ sections.push(m[1]);
15
+ for (const m of goMod.matchAll(/^require\s+(?!\()(.+)$/gm))
16
+ sections.push(m[1]);
17
+ return sections.length > 0 ? sections.join("\n") : goMod;
18
+ }
7
19
  function detect(signals) {
8
20
  const source = signals.goMod;
9
21
  if (!source)
10
22
  return null;
11
- const lower = source.toLowerCase();
23
+ const lower = extractGoRequireSection(source).toLowerCase();
12
24
  let framework = { value: "none", confidence: "low" };
13
25
  for (const [needle, label] of FRAMEWORKS) {
14
26
  if (lower.includes(needle)) {
@@ -4,7 +4,11 @@ function detect(signals) {
4
4
  return null;
5
5
  // A Gradle build that applies the Kotlin plugin is a Kotlin project, not Java —
6
6
  // defer entirely to the Kotlin pack instead of also reporting a spurious Java match.
7
- if (/kotlin\(|org\.jetbrains\.kotlin/i.test(source))
7
+ // Modern Gradle projects often declare plugins via version catalogs
8
+ // (`alias(libs.plugins.kotlin.android)`) rather than the literal `kotlin("jvm")` or
9
+ // `org.jetbrains.kotlin` plugin id, so a plain word-boundary check on "kotlin" is
10
+ // more robust than trying to match every DSL style.
11
+ if (/\bkotlin\b/i.test(source))
8
12
  return null;
9
13
  const framework = /spring/i.test(source)
10
14
  ? { value: "spring", confidence: "high" }
@@ -45,18 +45,33 @@ function detect(signals) {
45
45
  : signals.hasFile("package-lock.json")
46
46
  ? { value: "npm", confidence: "high" }
47
47
  : { value: "npm", confidence: "low" };
48
- return { packId: "js-ts", language: "TypeScript/JavaScript", framework, testRunner, linter, packageManager };
48
+ const usesTypeScript = Boolean(allDeps.typescript) || signals.hasFile("tsconfig.json");
49
+ const moduleFormat = signals.packageJson.moduleType;
50
+ return {
51
+ packId: "js-ts",
52
+ language: usesTypeScript ? "TypeScript" : "JavaScript",
53
+ framework,
54
+ testRunner,
55
+ linter,
56
+ packageManager,
57
+ usesTypeScript,
58
+ moduleFormat,
59
+ };
49
60
  }
50
61
  function rules(detection) {
51
62
  const framework = detection.framework?.value ?? "none";
52
63
  const testRunner = detection.testRunner?.value ?? "unknown";
64
+ const conventions = [];
65
+ if (detection.usesTypeScript) {
66
+ conventions.push("Usa TypeScript estricto; evita `any` salvo justificación explícita.");
67
+ }
68
+ conventions.push(`Ejecuta los tests con ${testRunner === "unknown" ? "el test runner del proyecto" : testRunner} antes de dar por terminada una tarea.`);
69
+ conventions.push(detection.moduleFormat === "module"
70
+ ? "Sigue el estilo de módulos ES existente (import/export), no mezcles con require()."
71
+ : "Sigue el estilo CommonJS existente (require()/module.exports), no mezcles con import/export.");
53
72
  return {
54
- summary: `Proyecto JavaScript/TypeScript${framework !== "none" ? ` con ${framework}` : ""}.`,
55
- conventions: [
56
- "Usa TypeScript estricto; evita `any` salvo justificación explícita.",
57
- `Ejecuta los tests con ${testRunner === "unknown" ? "el test runner del proyecto" : testRunner} antes de dar por terminada una tarea.`,
58
- "Sigue el estilo de módulos ES existente (import/export), no mezcles con require().",
59
- ],
73
+ summary: `Proyecto ${detection.usesTypeScript ? "TypeScript" : "JavaScript"}${framework !== "none" ? ` con ${framework}` : ""}.`,
74
+ conventions,
60
75
  architectureNotes: [
61
76
  "Mantén los componentes/módulos pequeños y con una responsabilidad clara.",
62
77
  "Coloca los tests junto al código que prueban o en un directorio `test/` espejo, según lo que ya use el repo.",
@@ -2,7 +2,10 @@ function detect(signals) {
2
2
  const source = signals.buildGradle;
3
3
  if (!source)
4
4
  return null;
5
- if (!/kotlin\(|org\.jetbrains\.kotlin/i.test(source))
5
+ // Modern Gradle projects often declare the Kotlin plugin via a version catalog alias
6
+ // (`alias(libs.plugins.kotlin.android)`) rather than the literal `kotlin("jvm")` or
7
+ // `org.jetbrains.kotlin` plugin id, so a plain word-boundary check is more robust.
8
+ if (!/\bkotlin\b/i.test(source))
6
9
  return null;
7
10
  const framework = /ktor/i.test(source)
8
11
  ? { value: "ktor", confidence: "high" }
@@ -15,12 +15,32 @@ function findIn(haystack, table) {
15
15
  }
16
16
  return undefined;
17
17
  }
18
+ // Searching the whole pyproject.toml text (project name, URLs, script entry points, etc.)
19
+ // produces false positives — e.g. Flask's own pyproject.toml has `name = "flask"`, which
20
+ // isn't a dependency at all. Scope the search to the actual dependency declarations.
21
+ function extractPyprojectDependencySections(pyproject) {
22
+ const sections = [];
23
+ const mainDeps = pyproject.match(/(?:^|\n)dependencies\s*=\s*\[([\s\S]*?)\]/);
24
+ if (mainDeps)
25
+ sections.push(mainDeps[1]);
26
+ const optionalDeps = pyproject.match(/\[project\.optional-dependencies\]([\s\S]*?)(?:\n\[|$)/);
27
+ if (optionalDeps)
28
+ sections.push(optionalDeps[1]);
29
+ const dependencyGroups = pyproject.match(/\[dependency-groups\]([\s\S]*?)(?:\n\[|$)/);
30
+ if (dependencyGroups)
31
+ sections.push(dependencyGroups[1]);
32
+ const poetryDepsBlocks = pyproject.match(/\[tool\.poetry(?:\.group\.\w+)?\.dependencies\]([\s\S]*?)(?:\n\[|$)/g);
33
+ if (poetryDepsBlocks)
34
+ sections.push(...poetryDepsBlocks);
35
+ return sections.length > 0 ? sections.join("\n") : pyproject;
36
+ }
18
37
  function detect(signals) {
19
38
  const source = signals.pyprojectToml ?? signals.requirementsTxt ?? signals.environmentYml;
20
39
  if (!source)
21
40
  return null;
22
- const framework = findIn(source, FRAMEWORKS) ?? { value: "none", confidence: "low" };
23
- const testRunner = findIn(source, TEST_RUNNERS) ?? { value: "unknown", confidence: "low" };
41
+ const searchText = signals.pyprojectToml ? extractPyprojectDependencySections(signals.pyprojectToml) : source;
42
+ const framework = findIn(searchText, FRAMEWORKS) ?? { value: "none", confidence: "low" };
43
+ const testRunner = findIn(searchText, TEST_RUNNERS) ?? { value: "unknown", confidence: "low" };
24
44
  const packageManager = signals.environmentYml
25
45
  ? { value: "conda", confidence: "high" }
26
46
  : signals.hasFile("poetry.lock")
@@ -2,10 +2,12 @@ const FRAMEWORKS = [
2
2
  ["playframework", "play"],
3
3
  ["akka-http", "akka"],
4
4
  ["akka.actor", "akka"],
5
+ ["scalatra", "scalatra"],
5
6
  ];
6
7
  const TEST_RUNNERS = [
7
8
  ["scalatest", "scalatest"],
8
9
  ["munit", "munit"],
10
+ ["specs2", "specs2"],
9
11
  ];
10
12
  function detect(signals) {
11
13
  const source = signals.buildSbt;
@@ -2,6 +2,12 @@ function detect(signals) {
2
2
  const source = signals.packageSwift;
3
3
  if (!source)
4
4
  return null;
5
+ // A Package.swift alone doesn't guarantee a Swift project: some C/C++ libraries (e.g.
6
+ // nlohmann/json) ship one purely so Swift Package Manager users can link the library,
7
+ // with zero actual Swift source. Require at least one other .swift file to be present.
8
+ const hasSwiftSource = signals.files.some((f) => f.toLowerCase().endsWith(".swift") && !f.endsWith("Package.swift"));
9
+ if (!hasSwiftSource)
10
+ return null;
5
11
  const lower = source.toLowerCase();
6
12
  const framework = lower.includes("vapor")
7
13
  ? { value: "vapor", confidence: "high" }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-rules-init",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Generates CLAUDE.md, AGENTS.md, copilot-instructions.md and prompt files from what your repo actually is.",
5
5
  "license": "MIT",
6
6
  "type": "module",