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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 agent-rules-init contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # agent-rules-init
2
+
3
+ Generate repository-specific instructions for Claude Code, Codex and GitHub Copilot from the manifests and commands that already exist in your project.
4
+
5
+ ```bash
6
+ npx agent-rules-init
7
+ ```
8
+
9
+ The CLI detects JavaScript/TypeScript, Python, Java, PHP, Ruby, Go, Rust, C#/.NET, Kotlin, Swift, Dart/Flutter, C/C++, Elixir, Scala and R projects. It also understands mixed repositories and npm, pnpm, Yarn and Bun workspaces, generating package-scoped `AGENTS.generated.md` files.
10
+
11
+ All output is written with a `.generated` suffix and existing files are never overwritten. Review the generated files and remove the suffix when you are ready to activate them.
12
+
13
+ Use `--lang es` or `--lang en` to select the output language. See the [full documentation](https://github.com/racama29/agent-rules-init#readme) for supported frameworks, generated files and contribution instructions.
14
+
15
+ Automation is supported through `--dry-run`, `--check`, `--json` and `--non-interactive`. Repository defaults and per-project overrides can be stored in `.agent-rules-init.yml`.
16
+
17
+ Generated documents share an evidence-backed model but are not duplicates: Claude receives broad repository context, AGENTS receives operational commands and scope, and Copilot receives concise implementation conventions. Observed architecture and local conventions include their source files so specific claims can be audited.
18
+
19
+ ## License
20
+
21
+ MIT
package/dist/cli.d.ts CHANGED
@@ -1,24 +1,58 @@
1
- import { type WriteResult } from "./core/writer.js";
1
+ import { type GeneratedFile, type WriteResult } from "./core/writer.js";
2
2
  import { type Lang } from "./core/i18n.js";
3
+ import { type AgentRulesConfig } from "./core/config.js";
3
4
  import { type PromptFn } from "./core/prompt-engine.js";
4
- import { type ExecFn } from "./core/llm-bridge.js";
5
+ import { type AssistantId, type ExecFn } from "./core/llm-bridge.js";
6
+ import type { RepoFacts } from "./core/types.js";
5
7
  export interface RunCliOptions {
6
8
  promptFn?: PromptFn;
7
9
  execFn?: ExecFn;
8
10
  skipLlm?: boolean;
9
11
  lang?: Lang;
12
+ /** Generate results without changing the filesystem. */
13
+ dryRun?: boolean;
14
+ /** Do not prompt or offer AI enrichment. */
15
+ nonInteractive?: boolean;
16
+ /** Run AI enrichment without asking, even without a TTY. Ignored when skipLlm or noAi apply. */
17
+ enrich?: boolean;
18
+ /** Assistant to enrich with; defaults to the first one installed. */
19
+ assistant?: AssistantId;
20
+ /** Model forwarded verbatim to the assistant CLI; its default when omitted. */
21
+ model?: string;
22
+ /** Receives the rendered files before they are written (or planned). */
23
+ onGeneratedFiles?: (files: readonly GeneratedFile[]) => void;
24
+ /** Preloaded repository configuration; loaded from disk when omitted. */
25
+ config?: AgentRulesConfig;
26
+ onConfigWarnings?: (warnings: readonly string[]) => void;
27
+ /** Receives the facts extracted from the repo (including canonical commands). */
28
+ onFacts?: (facts: RepoFacts) => void;
10
29
  }
11
30
  export declare function runCli(rootPath: string, options?: RunCliOptions): Promise<WriteResult[]>;
12
- export type CliAction = {
13
- kind: "run";
31
+ export interface CliRunOptions {
14
32
  lang?: Lang;
15
- } | {
33
+ dryRun?: true;
34
+ check?: true;
35
+ json?: true;
36
+ nonInteractive?: true;
37
+ enrich?: true;
38
+ assistant?: AssistantId;
39
+ model?: string;
40
+ }
41
+ export type CliAction = ({
42
+ kind: "run";
43
+ } & CliRunOptions) | {
16
44
  kind: "help";
17
45
  } | {
18
46
  kind: "version";
19
47
  } | {
20
48
  kind: "invalid-lang";
21
49
  value: string;
50
+ } | {
51
+ kind: "invalid-assistant";
52
+ value: string;
53
+ } | {
54
+ kind: "missing-value";
55
+ flag: string;
22
56
  } | {
23
57
  kind: "unknown";
24
58
  flag: string;
package/dist/cli.js CHANGED
@@ -1,12 +1,17 @@
1
1
  import { createRequire } from "node:module";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
2
4
  import * as clack from "@clack/prompts";
3
5
  import { scanRepo } from "./core/scanner.js";
4
6
  import { writeGeneratedFiles } from "./core/writer.js";
5
- import { renderClaudeMd, renderAgentsMd, renderCopilotInstructions, renderPromptFiles, renderRepoFacts, } from "./core/templates.js";
7
+ import { renderClaudeMd, renderAgentsMd, renderCopilotInstructions, renderPromptFiles, } from "./core/templates.js";
6
8
  import { buildRepoFacts } from "./core/repo-facts.js";
7
9
  import { UI, detectLang } from "./core/i18n.js";
10
+ import { loadConfig } from "./core/config.js";
11
+ import { applyProjectExcludes, buildPackageUnits } from "./core/project-units.js";
12
+ import { renderProjectUnitAgents } from "./core/project-unit-output.js";
8
13
  import { collectLowConfidenceQuestions, askQuestions, applyAnswers, makeDefaultPromptFn, hasInteractiveTty, } from "./core/prompt-engine.js";
9
- import { detectAvailableAssistants, polishWithAssistant, defaultExecFn } from "./core/llm-bridge.js";
14
+ import { detectAvailableAssistants, enrichFilesWithAssistant, defaultExecFn, } from "./core/llm-bridge.js";
10
15
  import { jsTsPack } from "./packs/js-ts.js";
11
16
  import { pythonPack } from "./packs/python.js";
12
17
  import { javaPack } from "./packs/java.js";
@@ -39,20 +44,42 @@ const ALL_PACKS = [
39
44
  scalaPack,
40
45
  rPack,
41
46
  ];
47
+ // Final-name docs the tool itself would generate; when they already exist they carry the
48
+ // team's hand-written intent, so enrichment must integrate them instead of ignoring them.
49
+ const EXISTING_DOC_PATHS = ["CLAUDE.md", "AGENTS.md", ".github/copilot-instructions.md"];
50
+ const MAX_EXISTING_DOC_CHARS = 20_000;
51
+ function readExistingDocs(rootPath) {
52
+ const docs = [];
53
+ for (const relativePath of EXISTING_DOC_PATHS) {
54
+ const absolutePath = path.join(rootPath, relativePath);
55
+ if (!fs.existsSync(absolutePath))
56
+ continue;
57
+ const content = fs.readFileSync(absolutePath, "utf8");
58
+ if (content.trim() === "")
59
+ continue;
60
+ docs.push({ path: relativePath, content: content.slice(0, MAX_EXISTING_DOC_CHARS) });
61
+ }
62
+ return docs;
63
+ }
42
64
  export async function runCli(rootPath, options = {}) {
65
+ const loadedConfig = options.config ? { config: options.config, warnings: [] } : loadConfig(rootPath);
66
+ const config = loadedConfig.config;
67
+ options.onConfigWarnings?.(loadedConfig.warnings);
43
68
  const execFn = options.execFn ?? defaultExecFn;
44
- const lang = options.lang ?? detectLang();
69
+ const lang = options.lang ?? config.lang ?? detectLang();
45
70
  const promptFn = options.promptFn ?? makeDefaultPromptFn(lang);
46
71
  const ui = UI[lang];
47
- const signals = scanRepo(rootPath);
72
+ const signals = applyProjectExcludes(scanRepo(rootPath), config.exclude ?? []);
48
73
  const rawDetections = ALL_PACKS.map((pack) => pack.detect(signals)).filter((d) => d !== null);
49
74
  const questions = collectLowConfidenceQuestions(rawDetections, lang);
50
- const answers = await askQuestions(questions, promptFn);
75
+ const answers = options.nonInteractive ? {} : await askQuestions(questions, promptFn);
51
76
  const detections = applyAnswers(rawDetections, answers);
52
77
  const facts = buildRepoFacts(signals, lang);
78
+ options.onFacts?.(facts);
79
+ const ctx = { facts, signals };
53
80
  const entries = detections.map((detection) => {
54
81
  const pack = ALL_PACKS.find((p) => p.id === detection.packId);
55
- return { detection, ruleSet: pack.rules(detection, lang) };
82
+ return { detection, ruleSet: pack.rules(detection, lang, ctx) };
56
83
  });
57
84
  const files = [];
58
85
  if (entries.length > 0) {
@@ -64,38 +91,88 @@ export async function runCli(rootPath, options = {}) {
64
91
  });
65
92
  for (const detection of detections) {
66
93
  const pack = ALL_PACKS.find((p) => p.id === detection.packId);
67
- for (const file of renderPromptFiles(detection.packId, pack.promptTemplates(detection, lang))) {
94
+ for (const file of renderPromptFiles(detection.packId, pack.promptTemplates(detection, lang, ctx))) {
68
95
  files.push(file);
69
96
  }
70
97
  }
71
98
  }
72
99
  else {
73
- const factsBlock = renderRepoFacts(facts, lang);
74
- files.push({
75
- path: "CLAUDE.generated.md",
76
- content: `# CLAUDE.md\n\n${ui.noStackFallback}\n` + (factsBlock ? `\n${factsBlock}\n` : ""),
100
+ const withFallback = (content) => content.replace(ui.generatedHeader, `${ui.generatedHeader}\n\n${ui.noStackFallback}`);
101
+ files.push({ path: "CLAUDE.generated.md", content: withFallback(renderClaudeMd([], facts, lang)) }, { path: "AGENTS.generated.md", content: withFallback(renderAgentsMd([], facts, lang)) }, {
102
+ path: ".github/copilot-instructions.generated.md",
103
+ content: withFallback(renderCopilotInstructions([], facts, lang)),
77
104
  });
78
105
  }
79
- if (!options.skipLlm && hasInteractiveTty()) {
106
+ // Nested AGENTS files are intentionally limited to package-local facts and stack
107
+ // signals. The root documents remain the cross-repository overview.
108
+ for (const unit of buildPackageUnits(signals)) {
109
+ const scoped = renderProjectUnitAgents(unit, lang, config.projects?.[unit.path]);
110
+ if (scoped)
111
+ files.push(scoped);
112
+ }
113
+ const wantsEnrich = options.enrich === true;
114
+ const canOfferEnrich = !options.nonInteractive && hasInteractiveTty();
115
+ if (!options.skipLlm && !config.noAi && (wantsEnrich || canOfferEnrich)) {
116
+ // With --enrich there may be no TTY (scripts, CI); route progress through stderr so
117
+ // stdout stays parseable in --json mode.
118
+ const notify = canOfferEnrich ? (message) => clack.log.info(message) : (message) => console.warn(message);
80
119
  const assistants = await detectAvailableAssistants(execFn);
81
- if (assistants.length > 0) {
82
- const chosenAssistant = assistants[0];
83
- clack.log.info(ui.polishDetected(chosenAssistant));
84
- const usePolish = await clack.confirm({ message: ui.polishConfirm(chosenAssistant) });
85
- if (usePolish === true) {
86
- for (const file of files) {
87
- file.content = await polishWithAssistant(chosenAssistant, file.content, execFn, lang);
88
- }
120
+ const requested = options.assistant;
121
+ if (requested && !assistants.includes(requested)) {
122
+ console.warn(ui.assistantNotAvailable(requested));
123
+ }
124
+ else if (assistants.length === 0) {
125
+ if (wantsEnrich)
126
+ console.warn(ui.enrichNoAssistant);
127
+ }
128
+ else {
129
+ const chosenAssistant = requested ?? assistants[0];
130
+ let proceed = wantsEnrich;
131
+ if (!proceed) {
132
+ clack.log.info(ui.enrichDetected(chosenAssistant));
133
+ proceed = (await clack.confirm({ message: ui.enrichConfirm(chosenAssistant) })) === true;
134
+ }
135
+ if (proceed) {
136
+ const spinner = canOfferEnrich ? clack.spinner() : undefined;
137
+ if (spinner)
138
+ spinner.start(ui.enrichWorking(chosenAssistant));
139
+ else
140
+ notify(ui.enrichWorking(chosenAssistant));
141
+ const enriched = await enrichFilesWithAssistant(chosenAssistant, files, {
142
+ execFn,
143
+ lang,
144
+ cwd: rootPath,
145
+ mustKeep: facts.canonical.map((c) => c.command),
146
+ existingDocs: readExistingDocs(rootPath),
147
+ model: options.model,
148
+ });
149
+ const changed = enriched.some((file, i) => file.content !== files[i].content);
150
+ const outcome = changed ? ui.enrichDone : ui.enrichKept;
151
+ if (spinner)
152
+ spinner.stop(outcome);
153
+ else
154
+ notify(outcome);
155
+ files.splice(0, files.length, ...enriched);
89
156
  }
90
157
  }
91
158
  }
159
+ options.onGeneratedFiles?.(files);
160
+ if (options.dryRun) {
161
+ return files.map((file) => ({
162
+ path: file.path,
163
+ status: fs.existsSync(path.join(rootPath, file.path)) ? "skipped" : "written",
164
+ }));
165
+ }
92
166
  return writeGeneratedFiles(rootPath, files);
93
167
  }
94
168
  function isLang(value) {
95
169
  return value === "es" || value === "en";
96
170
  }
171
+ function isAssistant(value) {
172
+ return value === "claude" || value === "codex";
173
+ }
97
174
  export function resolveCliAction(argv) {
98
- let lang;
175
+ const options = {};
99
176
  for (let i = 0; i < argv.length; i++) {
100
177
  const arg = argv[i];
101
178
  if (arg === "--help" || arg === "-h")
@@ -106,12 +183,49 @@ export function resolveCliAction(argv) {
106
183
  const value = arg.startsWith("--lang=") ? arg.slice("--lang=".length) : argv[++i] ?? "";
107
184
  if (!isLang(value))
108
185
  return { kind: "invalid-lang", value };
109
- lang = value;
186
+ options.lang = value;
187
+ continue;
188
+ }
189
+ if (arg === "--dry-run") {
190
+ options.dryRun = true;
191
+ continue;
192
+ }
193
+ if (arg === "--check") {
194
+ options.check = true;
195
+ continue;
196
+ }
197
+ if (arg === "--json") {
198
+ options.json = true;
199
+ continue;
200
+ }
201
+ if (arg === "--non-interactive") {
202
+ options.nonInteractive = true;
203
+ continue;
204
+ }
205
+ if (arg === "--enrich") {
206
+ options.enrich = true;
207
+ continue;
208
+ }
209
+ if (arg === "--assistant" || arg.startsWith("--assistant=")) {
210
+ const value = arg.startsWith("--assistant=") ? arg.slice("--assistant=".length) : argv[++i] ?? "";
211
+ if (!isAssistant(value))
212
+ return { kind: "invalid-assistant", value };
213
+ options.assistant = value;
214
+ continue;
215
+ }
216
+ if (arg === "--model" || arg.startsWith("--model=")) {
217
+ const value = arg.startsWith("--model=") ? arg.slice("--model=".length) : argv[++i] ?? "";
218
+ if (value === "")
219
+ return { kind: "missing-value", flag: "--model" };
220
+ options.model = value;
110
221
  continue;
111
222
  }
112
223
  return { kind: "unknown", flag: arg };
113
224
  }
114
- return lang ? { kind: "run", lang } : { kind: "run" };
225
+ return { kind: "run", ...options };
226
+ }
227
+ function usageWithAutomationOptions(ui) {
228
+ return `${ui.usage}\n\n${ui.automationUsage}`;
115
229
  }
116
230
  export function getVersion() {
117
231
  // Works both from src/ (tests) and dist/ (published bin): ../package.json
@@ -121,10 +235,10 @@ export function getVersion() {
121
235
  }
122
236
  export async function main() {
123
237
  const action = resolveCliAction(process.argv.slice(2));
124
- const lang = action.kind === "run" && action.lang ? action.lang : detectLang();
125
- const ui = UI[lang];
238
+ const defaultLang = action.kind === "run" && action.lang ? action.lang : detectLang();
239
+ let ui = UI[defaultLang];
126
240
  if (action.kind === "help") {
127
- console.log(ui.usage);
241
+ console.log(usageWithAutomationOptions(ui));
128
242
  return;
129
243
  }
130
244
  if (action.kind === "version") {
@@ -132,46 +246,131 @@ export async function main() {
132
246
  return;
133
247
  }
134
248
  if (action.kind === "unknown") {
135
- console.error(`${ui.unknownOption(action.flag)}\n\n${ui.usage}`);
249
+ console.error(`${ui.unknownOption(action.flag)}\n\n${usageWithAutomationOptions(ui)}`);
136
250
  process.exitCode = 1;
137
251
  return;
138
252
  }
139
253
  if (action.kind === "invalid-lang") {
140
- console.error(`${ui.invalidLang(action.value)}\n\n${ui.usage}`);
254
+ console.error(`${ui.invalidLang(action.value)}\n\n${usageWithAutomationOptions(ui)}`);
255
+ process.exitCode = 1;
256
+ return;
257
+ }
258
+ if (action.kind === "invalid-assistant") {
259
+ console.error(`${ui.invalidAssistant(action.value)}\n\n${usageWithAutomationOptions(ui)}`);
260
+ process.exitCode = 1;
261
+ return;
262
+ }
263
+ if (action.kind === "missing-value") {
264
+ console.error(`${ui.missingFlagValue(action.flag)}\n\n${usageWithAutomationOptions(ui)}`);
141
265
  process.exitCode = 1;
142
266
  return;
143
267
  }
144
- clack.intro("agent-rules-init");
145
- if (!hasInteractiveTty()) {
268
+ const machineOutput = action.json === true;
269
+ const planningOnly = action.dryRun === true || action.check === true;
270
+ const nonInteractive = action.nonInteractive === true || machineOutput || planningOnly;
271
+ // Enrichment output is non-deterministic, so --check (freshness comparison) must stay
272
+ // on the deterministic baseline.
273
+ const enrich = action.enrich === true && action.check !== true;
274
+ if (action.enrich === true && action.check === true)
275
+ console.warn("--enrich is ignored with --check.");
276
+ if (!machineOutput)
277
+ clack.intro("agent-rules-init");
278
+ if (!machineOutput && !nonInteractive && !hasInteractiveTty()) {
146
279
  console.warn(ui.noTtyWarning);
147
280
  }
148
281
  try {
149
- const results = await runCli(process.cwd(), { lang });
282
+ const loadedConfig = loadConfig(process.cwd());
283
+ const lang = action.lang ?? loadedConfig.config.lang ?? defaultLang;
284
+ ui = UI[lang];
285
+ if (!machineOutput) {
286
+ for (const warning of loadedConfig.warnings)
287
+ console.warn(warning);
288
+ }
289
+ let generatedFiles = [];
290
+ let repoFacts;
291
+ const results = await runCli(process.cwd(), {
292
+ lang,
293
+ config: loadedConfig.config,
294
+ dryRun: planningOnly,
295
+ nonInteractive,
296
+ skipLlm: nonInteractive && !enrich,
297
+ enrich,
298
+ assistant: action.assistant,
299
+ model: action.model,
300
+ onGeneratedFiles: (files) => {
301
+ generatedFiles = files;
302
+ },
303
+ onFacts: (facts) => {
304
+ repoFacts = facts;
305
+ },
306
+ });
150
307
  const written = results.filter((r) => r.status === "written");
151
308
  const failures = results.filter((r) => r.status === "error");
152
- for (const result of results) {
153
- if (result.status === "written") {
154
- clack.log.success(result.path);
155
- }
156
- else if (result.status === "skipped") {
157
- clack.log.info(ui.fileSkipped(result.path));
309
+ const outdated = action.check
310
+ ? generatedFiles.filter((file) => {
311
+ const absolutePath = path.join(process.cwd(), file.path);
312
+ return fs.existsSync(absolutePath) && fs.readFileSync(absolutePath, "utf8") !== file.content;
313
+ })
314
+ : [];
315
+ const checkIssues = written.length + outdated.length;
316
+ if (machineOutput) {
317
+ const contentByPath = new Map(generatedFiles.map((file) => [file.path, file.content]));
318
+ console.log(JSON.stringify({
319
+ mode: action.check ? "check" : action.dryRun ? "dry-run" : "write",
320
+ configWarnings: loadedConfig.warnings,
321
+ facts: repoFacts,
322
+ wouldCreate: written.length,
323
+ outdated: outdated.map((file) => file.path),
324
+ results: results.map((result) => ({
325
+ ...result,
326
+ ...(planningOnly ? { content: contentByPath.get(result.path) } : {}),
327
+ })),
328
+ }));
329
+ }
330
+ else if (action.dryRun) {
331
+ const statusByPath = new Map(results.map((result) => [result.path, result.status]));
332
+ for (const file of generatedFiles) {
333
+ console.log(`\n--- ${file.path} (${statusByPath.get(file.path) === "written" ? "would create" : "exists"}) ---\n${file.content}`);
158
334
  }
159
- else {
160
- clack.log.warn(`${result.path}: ${result.error}`);
335
+ }
336
+ else if (!action.check)
337
+ for (const result of results) {
338
+ if (result.status === "written") {
339
+ clack.log.success(result.path);
340
+ }
341
+ else if (result.status === "skipped") {
342
+ clack.log.info(ui.fileSkipped(result.path));
343
+ }
344
+ else {
345
+ clack.log.warn(`${result.path}: ${result.error}`);
346
+ }
161
347
  }
348
+ if (!machineOutput && action.check) {
349
+ console.log(checkIssues > 0
350
+ ? `${written.length} file(s) missing; ${outdated.length} file(s) outdated.`
351
+ : "Generated files are present and up to date.");
352
+ }
353
+ else if (!machineOutput && action.dryRun) {
354
+ console.log(`\n${written.length} file(s) would be generated.`);
162
355
  }
163
- if (written.length > 0) {
356
+ else if (!machineOutput && written.length > 0) {
164
357
  clack.outro(ui.outroWritten);
165
358
  }
166
- else {
359
+ else if (!machineOutput) {
167
360
  clack.outro(ui.outroNothing);
168
361
  }
169
- if (failures.length > 0) {
362
+ if (failures.length > 0 || (action.check && checkIssues > 0)) {
170
363
  process.exitCode = 1;
171
364
  }
172
365
  }
173
366
  catch (err) {
174
- clack.log.error(ui.unexpectedError(err.message));
367
+ const message = ui.unexpectedError(err.message);
368
+ if (machineOutput) {
369
+ console.log(JSON.stringify({ mode: action.check ? "check" : action.dryRun ? "dry-run" : "write", error: message }));
370
+ }
371
+ else {
372
+ clack.log.error(message);
373
+ }
175
374
  process.exitCode = 1;
176
375
  }
177
376
  }
@@ -0,0 +1,4 @@
1
+ import type { CanonicalCommand, CiCommand, CommandEntry, CommandSource, PackContext, RepoSignals } from "./types.js";
2
+ export declare const SOURCE_FILES: Record<CommandSource, string>;
3
+ export declare function selectCanonicalCommands(signals: RepoSignals, commands: CommandEntry[], ciCommands: CiCommand[]): CanonicalCommand[];
4
+ export declare function canonicalOf(ctx: PackContext | undefined, kind: CanonicalCommand["kind"], family?: "js-ts" | "python" | "java"): CanonicalCommand | undefined;
@@ -0,0 +1,169 @@
1
+ export const SOURCE_FILES = {
2
+ npm: "package.json",
3
+ pnpm: "package.json",
4
+ yarn: "package.json",
5
+ bun: "package.json",
6
+ composer: "composer.json",
7
+ make: "Makefile",
8
+ mix: "mix.exs",
9
+ tox: "tox.ini",
10
+ };
11
+ const KIND_ORDER = ["test", "lint", "build", "format", "typecheck"];
12
+ // Nombre de script → kind. Solo nombres inequívocos; "check" o "ci" quedan fuera a propósito.
13
+ const SCRIPT_KINDS = {
14
+ test: "test",
15
+ lint: "lint",
16
+ build: "build",
17
+ format: "format",
18
+ fmt: "format",
19
+ typecheck: "typecheck",
20
+ "type-check": "typecheck",
21
+ };
22
+ function scriptName(entry) {
23
+ const parts = entry.invocation.split(" ");
24
+ return parts[parts.length - 1];
25
+ }
26
+ function isRootManifest(entry) {
27
+ return entry.manifestPath === undefined || entry.manifestPath === "package.json";
28
+ }
29
+ function fromScripts(commands) {
30
+ const found = [];
31
+ for (const entry of commands) {
32
+ if (!isRootManifest(entry))
33
+ continue;
34
+ const kind = SCRIPT_KINDS[scriptName(entry)];
35
+ if (!kind || found.some((c) => c.kind === kind))
36
+ continue;
37
+ found.push({
38
+ kind,
39
+ command: entry.invocation,
40
+ source: entry.manifestPath ?? SOURCE_FILES[entry.source],
41
+ confidence: "high",
42
+ scope: ".",
43
+ });
44
+ }
45
+ return found;
46
+ }
47
+ // El comando de CI se conserva textual (evidencia), solo se clasifica su kind.
48
+ const CI_PATTERNS = [
49
+ [/^(?:npm|pnpm|yarn|bun)(?: run)? test\b/, "test"],
50
+ [/^(?:npm|pnpm|yarn|bun) run lint\b/, "lint"],
51
+ [/^(?:npm|pnpm|yarn|bun) run build\b/, "build"],
52
+ [/^(?:npm|pnpm|yarn|bun) run (?:format|fmt)\b/, "format"],
53
+ [/^(?:npm|pnpm|yarn|bun) run (?:typecheck|type-check)\b/, "typecheck"],
54
+ [/^(?:\.[\\/])?mvnw(?:\.cmd)?\b.*\b(?:verify|test)\b/, "test"],
55
+ [/^(?:\.[\\/])?mvnw(?:\.cmd)?\b.*\bpackage\b/, "build"],
56
+ [/^(?:\.[\\/])?gradlew(?:\.bat)?\b.*\b(?:check|test)\b/, "test"],
57
+ [/^(?:\.[\\/])?gradlew(?:\.bat)?\b.*\b(?:build|assemble)\b/, "build"],
58
+ [/^uv run\b.*\b(?:pytest|tox(?:\s+run)?)\b/, "test"],
59
+ [/^poetry run\b.*\b(?:pytest|tox)\b/, "test"],
60
+ [/^pytest\b/, "test"],
61
+ [/^tox(?:\s+run)?\b/, "test"],
62
+ [/^cargo test\b/, "test"],
63
+ [/^go test\b/, "test"],
64
+ ];
65
+ function fromCi(ciCommands) {
66
+ const found = [];
67
+ for (const ci of ciCommands) {
68
+ for (const [pattern, kind] of CI_PATTERNS) {
69
+ if (!pattern.test(ci.command))
70
+ continue;
71
+ if (found.some((c) => c.kind === kind))
72
+ break;
73
+ found.push({
74
+ kind,
75
+ command: ci.command,
76
+ source: `ci: ${ci.workflow}`,
77
+ confidence: "high",
78
+ scope: ".",
79
+ });
80
+ break;
81
+ }
82
+ }
83
+ return found;
84
+ }
85
+ function fromSignals(signals) {
86
+ const out = [];
87
+ if (signals.pomXml) {
88
+ const unixWrapper = signals.hasFile("mvnw");
89
+ const windowsWrapper = !unixWrapper && signals.hasFile("mvnw.cmd");
90
+ const hasWrapper = unixWrapper || windowsWrapper;
91
+ out.push({
92
+ kind: "test",
93
+ command: unixWrapper ? "./mvnw test" : windowsWrapper ? "mvnw.cmd test" : "mvn test",
94
+ source: unixWrapper ? "mvnw" : windowsWrapper ? "mvnw.cmd" : "pom.xml",
95
+ confidence: hasWrapper ? "high" : "low",
96
+ scope: ".",
97
+ });
98
+ }
99
+ else if (signals.buildGradle) {
100
+ const unixWrapper = signals.hasFile("gradlew");
101
+ const windowsWrapper = !unixWrapper && signals.hasFile("gradlew.bat");
102
+ const hasWrapper = unixWrapper || windowsWrapper;
103
+ out.push({
104
+ kind: "test",
105
+ command: unixWrapper ? "./gradlew test" : windowsWrapper ? "gradlew.bat test" : "gradle test",
106
+ source: unixWrapper ? "gradlew" : windowsWrapper ? "gradlew.bat" : "build.gradle",
107
+ confidence: hasWrapper ? "high" : "low",
108
+ scope: ".",
109
+ });
110
+ }
111
+ const pythonManifest = signals.pyprojectToml ?? signals.requirementsTxt ?? signals.environmentYml;
112
+ const hasPytest = pythonManifest !== undefined && /\bpytest\b/i.test(pythonManifest);
113
+ if (signals.toxIni) {
114
+ out.push({ kind: "test", command: "tox", source: "tox.ini", confidence: "high", scope: "." });
115
+ }
116
+ else if (hasPytest && signals.hasFile("uv.lock")) {
117
+ // El lock demuestra el gestor, no que pytest pertenezca a un grupo instalado por defecto.
118
+ out.push({ kind: "test", command: "uv run pytest", source: "uv.lock", confidence: "low", scope: "." });
119
+ }
120
+ else if (hasPytest && signals.hasFile("poetry.lock")) {
121
+ out.push({ kind: "test", command: "poetry run pytest", source: "poetry.lock", confidence: "low", scope: "." });
122
+ }
123
+ else if (hasPytest) {
124
+ const source = signals.pyprojectToml
125
+ ? "pyproject.toml"
126
+ : signals.requirementsTxt
127
+ ? "requirements.txt"
128
+ : "environment.yml";
129
+ out.push({ kind: "test", command: "pytest", source, confidence: "low", scope: "." });
130
+ }
131
+ return out;
132
+ }
133
+ export function selectCanonicalCommands(signals, commands, ciCommands) {
134
+ const byKind = new Map();
135
+ for (const candidate of [...fromScripts(commands), ...fromCi(ciCommands), ...fromSignals(signals)]) {
136
+ if (!byKind.has(candidate.kind))
137
+ byKind.set(candidate.kind, candidate);
138
+ }
139
+ return KIND_ORDER.flatMap((kind) => byKind.get(kind) ?? []);
140
+ }
141
+ export function canonicalOf(ctx, kind, family) {
142
+ if (family && ctx?.signals) {
143
+ return selectCanonicalForFamily({ ...ctx, signals: ctx.signals }, family).find((c) => c.kind === kind && c.confidence === "high");
144
+ }
145
+ return ctx?.facts.canonical.find((c) => c.kind === kind && c.confidence === "high");
146
+ }
147
+ function selectCanonicalForFamily(ctx, family) {
148
+ const jsSources = new Set(["npm", "pnpm", "yarn", "bun"]);
149
+ const commands = ctx.facts.commands.filter((command) => family === "js-ts" ? jsSources.has(command.source) : family === "python" ? command.source === "tox" : false);
150
+ const ciCommands = ctx.facts.ciCommands.filter(({ command }) => {
151
+ if (family === "js-ts")
152
+ return /^(?:npm|pnpm|yarn|bun)\b/.test(command);
153
+ if (family === "python")
154
+ return /^(?:uv|poetry|pytest|tox)\b/.test(command);
155
+ return /^(?:\.[\\/])?(?:mvnw(?:\.cmd)?|gradlew(?:\.bat)?)\b|^(?:mvn|gradle)\b/.test(command);
156
+ });
157
+ const signals = {
158
+ ...ctx.signals,
159
+ packageJson: family === "js-ts" ? ctx.signals.packageJson : undefined,
160
+ packageJsons: family === "js-ts" ? ctx.signals.packageJsons : undefined,
161
+ pyprojectToml: family === "python" ? ctx.signals.pyprojectToml : undefined,
162
+ requirementsTxt: family === "python" ? ctx.signals.requirementsTxt : undefined,
163
+ environmentYml: family === "python" ? ctx.signals.environmentYml : undefined,
164
+ toxIni: family === "python" ? ctx.signals.toxIni : undefined,
165
+ pomXml: family === "java" ? ctx.signals.pomXml : undefined,
166
+ buildGradle: family === "java" ? ctx.signals.buildGradle : undefined,
167
+ };
168
+ return selectCanonicalCommands(signals, commands, ciCommands);
169
+ }