@jazpiper/rules-doctor 0.1.0 → 0.3.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/dist/index.js CHANGED
@@ -1,32 +1,52 @@
1
1
  #!/usr/bin/env node
2
+ const { dirname, isAbsolute, resolve } = require("node:path");
2
3
  const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs");
3
- const { resolve } = require("node:path");
4
+ const { ADAPTERS, ADAPTERS_BY_ID } = require("./adapters");
4
5
 
5
- const RULES_FILE = resolve(".agentrules/rules.yaml");
6
- const CLAUDE_FILE = resolve("CLAUDE.md");
7
- const AGENTS_FILE = resolve("AGENTS.md");
8
- const MARKER_BEGIN = "<!-- RULES_DOCTOR:BEGIN -->";
9
- const MARKER_END = "<!-- RULES_DOCTOR:END -->";
6
+ const RULES_RELATIVE_PATH = ".agentrules/rules.yaml";
7
+ const IMPORT_REPORT_RELATIVE_PATH = ".agentrules/import-report.md";
8
+ const PRESET_NAMES = ["all", "core", "copilot"];
10
9
 
11
10
  function usage() {
11
+ const targets = ADAPTERS.map((adapter) => adapter.id).join("|");
12
+ const presets = PRESET_NAMES.join("|");
12
13
  return [
13
14
  "rules-doctor",
14
15
  "",
15
16
  "Usage:",
16
- " rules-doctor init",
17
- " rules-doctor sync [--target all|claude|codex]",
18
- " rules-doctor analyze",
17
+ ` rules-doctor init [--import] [--preset ${presets}]`,
18
+ ` rules-doctor preset apply <${presets}> [--diff] [--write]`,
19
+ ` rules-doctor sync [--target all|${targets}|<comma-separated-targets>] [--diff] [--write] [--backup]`,
20
+ ` rules-doctor check [--target all|${targets}|<comma-separated-targets>] [--diff]`,
21
+ " rules-doctor analyze [--strict]",
22
+ " rules-doctor doctor [--strict]",
23
+ " rules-doctor targets list",
24
+ "",
25
+ "Notes:",
26
+ " - sync defaults to dry-run. Add --write to apply changes.",
19
27
  ].join("\n");
20
28
  }
21
29
 
30
+ function createLogger(options) {
31
+ return {
32
+ log:
33
+ options && typeof options.stdout === "function"
34
+ ? options.stdout
35
+ : (message) => console.log(message),
36
+ error:
37
+ options && typeof options.stderr === "function"
38
+ ? options.stderr
39
+ : (message) => console.error(message),
40
+ };
41
+ }
42
+
22
43
  function readJsonFile(filePath) {
23
44
  if (!existsSync(filePath)) {
24
45
  return null;
25
46
  }
26
47
 
27
48
  try {
28
- const raw = readFileSync(filePath, "utf8");
29
- return JSON.parse(raw);
49
+ return JSON.parse(readFileSync(filePath, "utf8"));
30
50
  } catch {
31
51
  return null;
32
52
  }
@@ -49,6 +69,12 @@ function stripQuotes(value) {
49
69
 
50
70
  function parseScalar(value) {
51
71
  const cleaned = stripQuotes(value);
72
+ if (/^(true|false)$/i.test(cleaned)) {
73
+ return cleaned.toLowerCase() === "true";
74
+ }
75
+ if (cleaned === "null" || cleaned === "~") {
76
+ return null;
77
+ }
52
78
  if (/^-?\d+$/.test(cleaned)) {
53
79
  return Number(cleaned);
54
80
  }
@@ -70,6 +96,7 @@ function parseRulesText(text) {
70
96
  const lines = text.replace(/\r\n/g, "\n").split("\n");
71
97
  let section = null;
72
98
  let nested = null;
99
+ let currentTarget = null;
73
100
 
74
101
  for (const rawLine of lines) {
75
102
  if (!rawLine.trim() || rawLine.trim().startsWith("#")) {
@@ -81,6 +108,8 @@ function parseRulesText(text) {
81
108
 
82
109
  if (indent === 0) {
83
110
  nested = null;
111
+ currentTarget = null;
112
+
84
113
  const top = line.match(/^([a-zA-Z0-9_-]+):(.*)$/);
85
114
  if (!top) {
86
115
  continue;
@@ -88,11 +117,12 @@ function parseRulesText(text) {
88
117
 
89
118
  const key = top[1];
90
119
  const value = top[2].trim();
120
+
91
121
  if (!value) {
92
122
  section = key;
93
123
  if (key === "workflow" || key === "done") {
94
124
  data[key] = [];
95
- } else if (key === "commands" || key === "approvals") {
125
+ } else if (key === "commands" || key === "approvals" || key === "targets") {
96
126
  data[key] = {};
97
127
  }
98
128
  } else {
@@ -132,6 +162,35 @@ function parseRulesText(text) {
132
162
  if (nested === "notes" && line.startsWith("- ")) {
133
163
  data.approvals.notes.push(parseScalar(line.slice(2)));
134
164
  }
165
+ continue;
166
+ }
167
+
168
+ if (section === "targets") {
169
+ if (indent === 2) {
170
+ const target = line.match(/^([a-zA-Z0-9_-]+):(.*)$/);
171
+ if (!target) {
172
+ continue;
173
+ }
174
+
175
+ currentTarget = target[1];
176
+ const maybeValue = target[2].trim();
177
+ if (!maybeValue) {
178
+ data.targets[currentTarget] = {};
179
+ } else {
180
+ data.targets[currentTarget] = { path: parseScalar(maybeValue), enabled: true };
181
+ }
182
+ continue;
183
+ }
184
+
185
+ if (indent >= 4 && currentTarget) {
186
+ const pair = line.match(/^([a-zA-Z0-9_-]+):(.*)$/);
187
+ if (pair) {
188
+ if (!data.targets[currentTarget] || typeof data.targets[currentTarget] !== "object") {
189
+ data.targets[currentTarget] = {};
190
+ }
191
+ data.targets[currentTarget][pair[1]] = parseScalar(pair[2].trim());
192
+ }
193
+ }
135
194
  }
136
195
  }
137
196
 
@@ -139,31 +198,15 @@ function parseRulesText(text) {
139
198
  }
140
199
 
141
200
  function quoteYaml(value) {
201
+ if (typeof value === "number" || typeof value === "boolean") {
202
+ return String(value);
203
+ }
204
+ if (value === null || typeof value === "undefined") {
205
+ return "null";
206
+ }
142
207
  return JSON.stringify(String(value));
143
208
  }
144
209
 
145
- function stringifyRules(rules) {
146
- const lines = [
147
- `version: ${Number.isFinite(rules.version) ? rules.version : 1}`,
148
- `mission: ${quoteYaml(rules.mission)}`,
149
- "workflow:",
150
- ...rules.workflow.map((step) => ` - ${quoteYaml(step)}`),
151
- "commands:",
152
- ...Object.keys(rules.commands).map(
153
- (name) => ` ${name}: ${quoteYaml(rules.commands[name])}`,
154
- ),
155
- "done:",
156
- ...rules.done.map((item) => ` - ${quoteYaml(item)}`),
157
- "approvals:",
158
- ` mode: ${quoteYaml(rules.approvals.mode)}`,
159
- " notes:",
160
- ...rules.approvals.notes.map((note) => ` - ${quoteYaml(note)}`),
161
- "",
162
- ];
163
-
164
- return lines.join("\n");
165
- }
166
-
167
210
  function inferCommandFromScripts(scripts, scriptName) {
168
211
  if (scripts && typeof scripts[scriptName] === "string") {
169
212
  return `npm run ${scriptName}`;
@@ -171,12 +214,17 @@ function inferCommandFromScripts(scripts, scriptName) {
171
214
  return `echo "TODO: define ${scriptName} command"`;
172
215
  }
173
216
 
174
- function createDefaultRules() {
175
- const pkg = readJsonFile(resolve("package.json"));
176
- const scripts = pkg && typeof pkg === "object" ? pkg.scripts : undefined;
217
+ function createDefaultRules(scripts) {
218
+ const targets = {};
219
+ for (const adapter of ADAPTERS) {
220
+ targets[adapter.id] = {
221
+ enabled: true,
222
+ path: adapter.defaultPath,
223
+ };
224
+ }
177
225
 
178
226
  return {
179
- version: 1,
227
+ version: 2,
180
228
  mission: "Ship safe changes quickly while keeping agent instructions consistent.",
181
229
  workflow: [
182
230
  "Read relevant files before editing.",
@@ -196,322 +244,1178 @@ function createDefaultRules() {
196
244
  mode: "ask-before-destructive",
197
245
  notes: ["Ask before destructive actions or privileged operations."],
198
246
  },
247
+ targets,
248
+ };
249
+ }
250
+
251
+ function getPresetTargetIds(presetName) {
252
+ if (presetName === "all") {
253
+ return ADAPTERS.map((adapter) => adapter.id);
254
+ }
255
+
256
+ if (presetName === "core") {
257
+ return ["claude", "codex", "opencode", "cursor", "gemini"];
258
+ }
259
+
260
+ if (presetName === "copilot") {
261
+ return ["copilot"];
262
+ }
263
+
264
+ throw new Error(`Unknown preset "${presetName}". Use one of: ${PRESET_NAMES.join(", ")}`);
265
+ }
266
+
267
+ function applyTargetPreset(rules, presetName) {
268
+ const enabled = new Set(getPresetTargetIds(presetName));
269
+ const targets = rules.targets && typeof rules.targets === "object" ? rules.targets : {};
270
+
271
+ for (const adapter of ADAPTERS) {
272
+ const current = normalizeTargetConfig(targets[adapter.id], adapter.defaultPath);
273
+ targets[adapter.id] = {
274
+ enabled: enabled.has(adapter.id),
275
+ path: current.path,
276
+ };
277
+ }
278
+
279
+ return {
280
+ ...rules,
281
+ targets,
199
282
  };
200
283
  }
201
284
 
202
- function normalizeRules(input) {
285
+ function normalizeTargetConfig(source, fallbackPath) {
286
+ if (typeof source === "string" && source.trim()) {
287
+ return { enabled: true, path: source.trim() };
288
+ }
289
+
290
+ if (!source || typeof source !== "object") {
291
+ return { enabled: true, path: fallbackPath };
292
+ }
293
+
294
+ return {
295
+ enabled: typeof source.enabled === "boolean" ? source.enabled : true,
296
+ path: typeof source.path === "string" && source.path.trim() ? source.path.trim() : fallbackPath,
297
+ };
298
+ }
299
+
300
+ function normalizeRules(input, defaults) {
203
301
  const source = input && typeof input === "object" ? input : {};
204
302
  const commands = source.commands && typeof source.commands === "object" ? source.commands : {};
205
303
  const approvals =
206
304
  source.approvals && typeof source.approvals === "object" ? source.approvals : {};
305
+ const sourceTargets = source.targets && typeof source.targets === "object" ? source.targets : {};
207
306
 
208
307
  const workflow = Array.isArray(source.workflow)
209
308
  ? source.workflow.filter((item) => typeof item === "string")
210
- : ["Define your workflow steps."];
309
+ : defaults.workflow;
211
310
 
212
311
  const done = Array.isArray(source.done)
213
312
  ? source.done.filter((item) => typeof item === "string")
214
- : ["Define done criteria."];
313
+ : defaults.done;
215
314
 
216
315
  const notes = Array.isArray(approvals.notes)
217
316
  ? approvals.notes.filter((item) => typeof item === "string")
218
- : [];
317
+ : defaults.approvals.notes;
318
+
319
+ const targets = {};
320
+ for (const adapter of ADAPTERS) {
321
+ const fallback = defaults.targets[adapter.id]
322
+ ? defaults.targets[adapter.id].path
323
+ : adapter.defaultPath;
324
+ const config = normalizeTargetConfig(sourceTargets[adapter.id], fallback);
325
+ targets[adapter.id] = {
326
+ enabled: typeof config.enabled === "boolean" ? config.enabled : true,
327
+ path: config.path,
328
+ };
329
+ }
330
+
331
+ for (const customId of Object.keys(sourceTargets)) {
332
+ if (targets[customId]) {
333
+ continue;
334
+ }
335
+ targets[customId] = normalizeTargetConfig(sourceTargets[customId], `${customId.toUpperCase()}.md`);
336
+ }
219
337
 
220
338
  return {
221
- version: typeof source.version === "number" ? source.version : 1,
339
+ version: typeof source.version === "number" ? source.version : defaults.version,
222
340
  mission:
223
- typeof source.mission === "string" && source.mission.trim()
224
- ? source.mission
225
- : "Define your project mission.",
226
- workflow,
341
+ typeof source.mission === "string" && source.mission.trim() ? source.mission : defaults.mission,
342
+ workflow: workflow.length > 0 ? workflow : defaults.workflow,
227
343
  commands: {
228
- lint:
229
- typeof commands.lint === "string"
230
- ? commands.lint
231
- : 'echo "TODO: define lint command"',
232
- test:
233
- typeof commands.test === "string"
234
- ? commands.test
235
- : 'echo "TODO: define test command"',
236
- build:
237
- typeof commands.build === "string"
238
- ? commands.build
239
- : 'echo "TODO: define build command"',
344
+ lint: typeof commands.lint === "string" ? commands.lint : defaults.commands.lint,
345
+ test: typeof commands.test === "string" ? commands.test : defaults.commands.test,
346
+ build: typeof commands.build === "string" ? commands.build : defaults.commands.build,
240
347
  },
241
- done,
348
+ done: done.length > 0 ? done : defaults.done,
242
349
  approvals: {
243
- mode:
244
- typeof approvals.mode === "string" ? approvals.mode : "ask-before-destructive",
350
+ mode: typeof approvals.mode === "string" ? approvals.mode : defaults.approvals.mode,
245
351
  notes,
246
352
  },
353
+ targets,
247
354
  };
248
355
  }
249
356
 
250
- function readRulesOrThrow() {
251
- if (!existsSync(RULES_FILE)) {
252
- throw new Error(`Missing ${RULES_FILE}. Run "rules-doctor init" to create it first.`);
357
+ function stringifyRules(rules) {
358
+ const knownTargetIds = ADAPTERS.map((adapter) => adapter.id);
359
+ const allTargetIds = [
360
+ ...knownTargetIds.filter((id) => Object.prototype.hasOwnProperty.call(rules.targets || {}, id)),
361
+ ...Object.keys(rules.targets || {})
362
+ .filter((id) => !knownTargetIds.includes(id))
363
+ .sort(),
364
+ ];
365
+
366
+ const lines = [
367
+ `version: ${quoteYaml(Number.isFinite(rules.version) ? rules.version : 2)}`,
368
+ `mission: ${quoteYaml(rules.mission)}`,
369
+ "workflow:",
370
+ ...rules.workflow.map((step) => ` - ${quoteYaml(step)}`),
371
+ "commands:",
372
+ ...Object.keys(rules.commands).map((name) => ` ${name}: ${quoteYaml(rules.commands[name])}`),
373
+ "done:",
374
+ ...rules.done.map((item) => ` - ${quoteYaml(item)}`),
375
+ "approvals:",
376
+ ` mode: ${quoteYaml(rules.approvals.mode)}`,
377
+ " notes:",
378
+ ...rules.approvals.notes.map((note) => ` - ${quoteYaml(note)}`),
379
+ "targets:",
380
+ ];
381
+
382
+ for (const id of allTargetIds) {
383
+ const config = normalizeTargetConfig(rules.targets[id], `${id.toUpperCase()}.md`);
384
+ lines.push(` ${id}:`);
385
+ lines.push(` enabled: ${quoteYaml(config.enabled)}`);
386
+ lines.push(` path: ${quoteYaml(config.path)}`);
253
387
  }
254
388
 
255
- const raw = readFileSync(RULES_FILE, "utf8");
256
- return normalizeRules(parseRulesText(raw));
389
+ lines.push("");
390
+ return lines.join("\n");
257
391
  }
258
392
 
259
- function formatList(items) {
260
- if (!Array.isArray(items) || items.length === 0) {
261
- return "- (none)";
262
- }
263
- return items.map((item) => `- ${item}`).join("\n");
393
+ function hasVerifyCommand(text) {
394
+ return /\b(npm run|pnpm|yarn|bun)\s+(lint|test|build)\b/i.test(text);
264
395
  }
265
396
 
266
- function formatCommands(commands) {
267
- if (!commands || typeof commands !== "object") {
268
- return "- (none)";
269
- }
397
+ function hasNoApprovalLanguage(text) {
398
+ return /never ask (for )?approval|no approvals|without approval|do not ask for approval/i.test(
399
+ text,
400
+ );
401
+ }
270
402
 
271
- const preferredOrder = ["lint", "test", "build"];
272
- const names = [
273
- ...preferredOrder.filter((name) => Object.prototype.hasOwnProperty.call(commands, name)),
274
- ...Object.keys(commands).filter((name) => !preferredOrder.includes(name)),
275
- ];
403
+ function hasAskApprovalLanguage(text) {
404
+ return /ask for approval|request approval|require approval|needs approval/i.test(text);
405
+ }
276
406
 
277
- if (names.length === 0) {
278
- return "- (none)";
279
- }
407
+ function hasRequireTestsLanguage(text) {
408
+ return /must run tests|always run tests|run tests before done/i.test(text);
409
+ }
280
410
 
281
- return names.map((name) => `- ${name}: \`${commands[name]}\``).join("\n");
411
+ function hasSkipTestsLanguage(text) {
412
+ return /skip tests|tests optional|do not run tests/i.test(text);
282
413
  }
283
414
 
284
- function renderClaude(rules) {
285
- return [
286
- "# CLAUDE.md",
287
- "",
288
- "## Mission",
289
- rules.mission,
290
- "",
291
- "## Workflow",
292
- formatList(rules.workflow),
293
- "",
294
- "## Commands",
295
- formatCommands(rules.commands),
296
- "",
297
- "## Done",
298
- formatList(rules.done),
299
- "",
300
- "## Approvals",
301
- `- Mode: \`${rules.approvals.mode}\``,
302
- ...rules.approvals.notes.map((note) => `- ${note}`),
303
- "",
304
- ].join("\n");
415
+ function resolveInRoot(rootDir, filePath) {
416
+ if (isAbsolute(filePath)) {
417
+ return filePath;
418
+ }
419
+ return resolve(rootDir, filePath);
305
420
  }
306
421
 
307
- function renderCodexManagedSection(rules) {
308
- return [
309
- "## rules-doctor Managed Rules",
310
- "Generated from `.agentrules/rules.yaml`. Edit that file, then run `rules-doctor sync`.",
311
- "",
312
- "### Mission",
313
- rules.mission,
314
- "",
315
- "### Operational Loop",
316
- "1. Read relevant context and constraints before editing.",
317
- "2. Select and run the smallest command that moves the task forward.",
318
- "3. Apply focused changes.",
319
- "4. Run verification commands and report exact outcomes.",
320
- "",
321
- "### Commands",
322
- formatCommands(rules.commands),
323
- "",
324
- "### Failure Loop",
325
- "1. Capture the exact failing command and error output.",
326
- "2. Form one concrete hypothesis for the failure.",
327
- "3. Apply one fix and rerun the same command.",
328
- "4. Repeat until green or blocked, then report blocker and next action.",
329
- "",
330
- "### Done",
331
- formatList(rules.done),
332
- "",
333
- "### Approvals",
334
- `- Policy: \`${rules.approvals.mode}\``,
335
- ...rules.approvals.notes.map((note) => `- ${note}`),
336
- "",
337
- ].join("\n");
422
+ function findProjectRoot(startDir) {
423
+ let current = resolve(startDir);
424
+ while (true) {
425
+ const gitPath = resolve(current, ".git");
426
+ const rulesPath = resolve(current, RULES_RELATIVE_PATH);
427
+ if (existsSync(rulesPath) || existsSync(gitPath)) {
428
+ return current;
429
+ }
430
+
431
+ const parent = dirname(current);
432
+ if (parent === current) {
433
+ return resolve(startDir);
434
+ }
435
+ current = parent;
436
+ }
338
437
  }
339
438
 
340
- function upsertManagedSection(existing, content) {
341
- const start = existing.indexOf(MARKER_BEGIN);
342
- const end = start >= 0 ? existing.indexOf(MARKER_END, start) : -1;
439
+ function ensureParentDirectory(filePath) {
440
+ mkdirSync(dirname(filePath), { recursive: true });
441
+ }
442
+
443
+ function upsertManagedSection(existing, content, beginMarker, endMarker) {
444
+ const start = existing.indexOf(beginMarker);
445
+ const end = start >= 0 ? existing.indexOf(endMarker, start) : -1;
343
446
 
344
447
  if (start >= 0 && end > start) {
345
- const before = existing.slice(0, start + MARKER_BEGIN.length);
448
+ const before = existing.slice(0, start + beginMarker.length);
346
449
  const after = existing.slice(end);
347
450
  return `${before}\n${content.trim()}\n${after}`.replace(/\n{3,}/g, "\n\n");
348
451
  }
349
452
 
350
453
  const base = existing.trimEnd();
351
454
  const prefix = base ? `${base}\n\n` : "";
352
- return `${prefix}${MARKER_BEGIN}\n${content.trim()}\n${MARKER_END}\n`;
455
+ return `${prefix}${beginMarker}\n${content.trim()}\n${endMarker}\n`;
353
456
  }
354
457
 
355
- function initCommand() {
356
- if (existsSync(RULES_FILE)) {
357
- console.log(`rules.yaml already exists: ${RULES_FILE}`);
358
- return;
458
+ function loadPackageScripts(rootDir) {
459
+ const pkg = readJsonFile(resolve(rootDir, "package.json"));
460
+ if (!pkg || typeof pkg !== "object" || !pkg.scripts || typeof pkg.scripts !== "object") {
461
+ return {};
359
462
  }
463
+ return pkg.scripts;
464
+ }
360
465
 
361
- mkdirSync(resolve(".agentrules"), { recursive: true });
362
- writeFileSync(RULES_FILE, stringifyRules(createDefaultRules()), "utf8");
363
- console.log(`Created ${RULES_FILE}`);
466
+ function loadRules(rootDir, options) {
467
+ const rulesFile = resolve(rootDir, RULES_RELATIVE_PATH);
468
+ const defaults = createDefaultRules(loadPackageScripts(rootDir));
469
+
470
+ if (!existsSync(rulesFile)) {
471
+ if (options && options.allowMissing) {
472
+ return {
473
+ rules: defaults,
474
+ rulesFile,
475
+ rulesExists: false,
476
+ };
477
+ }
478
+ throw new Error(`Missing ${rulesFile}. Run "rules-doctor init" to create it first.`);
479
+ }
480
+
481
+ const parsed = parseRulesText(readFileSync(rulesFile, "utf8"));
482
+ return {
483
+ rules: normalizeRules(parsed, defaults),
484
+ rulesFile,
485
+ rulesExists: true,
486
+ };
364
487
  }
365
488
 
366
- function syncCommand(target) {
367
- const rules = readRulesOrThrow();
489
+ function getTargetsFromSpec(spec) {
490
+ if (spec === "all") {
491
+ return ADAPTERS.map((adapter) => adapter.id);
492
+ }
368
493
 
369
- if (target === "all" || target === "claude") {
370
- writeFileSync(CLAUDE_FILE, renderClaude(rules), "utf8");
371
- console.log(`Updated ${CLAUDE_FILE}`);
494
+ const unique = [];
495
+ for (const raw of spec.split(",")) {
496
+ const id = raw.trim();
497
+ if (!id) {
498
+ continue;
499
+ }
500
+ if (!ADAPTERS_BY_ID[id]) {
501
+ throw new Error(
502
+ `Unknown target "${id}". Use one of: all, ${ADAPTERS.map((adapter) => adapter.id).join(", ")}`,
503
+ );
504
+ }
505
+ if (!unique.includes(id)) {
506
+ unique.push(id);
507
+ }
372
508
  }
373
509
 
374
- if (target === "all" || target === "codex") {
375
- const existing = existsSync(AGENTS_FILE) ? readFileSync(AGENTS_FILE, "utf8") : "";
376
- const updated = upsertManagedSection(existing, renderCodexManagedSection(rules));
377
- writeFileSync(AGENTS_FILE, updated, "utf8");
378
- console.log(`Updated ${AGENTS_FILE}`);
510
+ if (unique.length === 0) {
511
+ throw new Error("No targets selected.");
379
512
  }
513
+
514
+ return unique;
380
515
  }
381
516
 
382
- function hasVerifyCommand(text) {
383
- return /\b(npm run|pnpm|yarn)\s+(lint|test|build)\b/i.test(text);
517
+ function parseInitArgs(args) {
518
+ const options = {
519
+ importExisting: false,
520
+ preset: "all",
521
+ };
522
+
523
+ for (let index = 0; index < (args || []).length; index += 1) {
524
+ const arg = args[index];
525
+ if (arg === "--import") {
526
+ options.importExisting = true;
527
+ continue;
528
+ }
529
+
530
+ if (arg === "--preset") {
531
+ const value = args[index + 1];
532
+ if (!value) {
533
+ throw new Error("Missing value for --preset.");
534
+ }
535
+ if (!PRESET_NAMES.includes(value)) {
536
+ throw new Error(`Unknown preset "${value}". Use one of: ${PRESET_NAMES.join(", ")}`);
537
+ }
538
+ options.preset = value;
539
+ index += 1;
540
+ continue;
541
+ }
542
+
543
+ throw new Error(`Unknown option for init: ${arg}`);
544
+ }
545
+
546
+ return options;
384
547
  }
385
548
 
386
- function hasNoApprovalLanguage(text) {
387
- return /never ask (for )?approval|no approvals|without approval|do not ask for approval/i.test(
388
- text,
389
- );
549
+ function parsePresetArgs(args) {
550
+ if (!args || args.length === 0) {
551
+ throw new Error(`Missing preset subcommand. Use "rules-doctor preset apply <${PRESET_NAMES.join("|")}>".`);
552
+ }
553
+
554
+ const [subcommand, presetName, ...rest] = args;
555
+ if (subcommand !== "apply") {
556
+ throw new Error(`Unknown preset subcommand: ${subcommand}. Use "rules-doctor preset apply <preset>".`);
557
+ }
558
+
559
+ if (!presetName) {
560
+ throw new Error(`Missing preset name. Use one of: ${PRESET_NAMES.join(", ")}`);
561
+ }
562
+ if (!PRESET_NAMES.includes(presetName)) {
563
+ throw new Error(`Unknown preset "${presetName}". Use one of: ${PRESET_NAMES.join(", ")}`);
564
+ }
565
+
566
+ const options = {
567
+ presetName,
568
+ diff: false,
569
+ write: false,
570
+ };
571
+
572
+ for (const arg of rest) {
573
+ if (arg === "--diff") {
574
+ options.diff = true;
575
+ continue;
576
+ }
577
+ if (arg === "--write") {
578
+ options.write = true;
579
+ continue;
580
+ }
581
+ throw new Error(`Unknown option for preset apply: ${arg}`);
582
+ }
583
+
584
+ return options;
390
585
  }
391
586
 
392
- function hasAskApprovalLanguage(text) {
393
- return /ask for approval|request approval|require approval|needs approval/i.test(text);
587
+ function parseTargetedArgs(commandName, args, extra) {
588
+ const options = {
589
+ targetSpec: "all",
590
+ diff: false,
591
+ write: false,
592
+ backup: false,
593
+ };
594
+ const allowed = extra || {};
595
+
596
+ for (let index = 0; index < (args || []).length; index += 1) {
597
+ const arg = args[index];
598
+ if (arg === "--target") {
599
+ const value = args[index + 1];
600
+ if (!value) {
601
+ throw new Error(`Missing value for --target (${commandName})`);
602
+ }
603
+ options.targetSpec = value;
604
+ index += 1;
605
+ continue;
606
+ }
607
+ if (arg === "--diff") {
608
+ options.diff = true;
609
+ continue;
610
+ }
611
+ if (arg === "--write" && allowed.write) {
612
+ options.write = true;
613
+ continue;
614
+ }
615
+ if (arg === "--backup" && allowed.backup) {
616
+ options.backup = true;
617
+ continue;
618
+ }
619
+ throw new Error(`Unknown option for ${commandName}: ${arg}`);
620
+ }
621
+
622
+ if (options.backup && !options.write) {
623
+ throw new Error("--backup requires --write.");
624
+ }
625
+
626
+ return {
627
+ targetIds: getTargetsFromSpec(options.targetSpec),
628
+ diff: options.diff,
629
+ write: options.write,
630
+ backup: options.backup,
631
+ };
394
632
  }
395
633
 
396
- function hasRequireTestsLanguage(text) {
397
- return /must run tests|always run tests|run tests before done/i.test(text);
634
+ function parseAnalyzeArgs(args) {
635
+ const options = {
636
+ strict: false,
637
+ };
638
+
639
+ for (const arg of args || []) {
640
+ if (arg === "--strict") {
641
+ options.strict = true;
642
+ continue;
643
+ }
644
+ throw new Error(`Unknown option for analyze: ${arg}`);
645
+ }
646
+
647
+ return options;
398
648
  }
399
649
 
400
- function hasSkipTestsLanguage(text) {
401
- return /skip tests|tests optional|do not run tests/i.test(text);
650
+ function parseDoctorArgs(args) {
651
+ const options = {
652
+ strict: false,
653
+ };
654
+
655
+ for (const arg of args || []) {
656
+ if (arg === "--strict") {
657
+ options.strict = true;
658
+ continue;
659
+ }
660
+ throw new Error(`Unknown option for doctor: ${arg}`);
661
+ }
662
+
663
+ return options;
402
664
  }
403
665
 
404
- function analyzeCommand() {
405
- const claudeExists = existsSync(CLAUDE_FILE);
406
- const agentsExists = existsSync(AGENTS_FILE);
407
- const claude = claudeExists ? readFileSync(CLAUDE_FILE, "utf8") : "";
408
- const agents = agentsExists ? readFileSync(AGENTS_FILE, "utf8") : "";
409
- const issues = [];
666
+ function getTargetConfig(rules, adapter) {
667
+ const source = rules.targets && typeof rules.targets === "object" ? rules.targets[adapter.id] : null;
668
+ return normalizeTargetConfig(source, adapter.defaultPath);
669
+ }
670
+
671
+ function normalizeHeading(value) {
672
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
673
+ }
410
674
 
411
- if (!claudeExists) {
412
- issues.push("CLAUDE.md missing.");
675
+ function parseMarkdownSections(text) {
676
+ const sections = {};
677
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
678
+ let currentHeading = null;
679
+ let currentLines = [];
680
+
681
+ function flush() {
682
+ if (!currentHeading) {
683
+ return;
684
+ }
685
+ const normalized = normalizeHeading(currentHeading);
686
+ if (!sections[normalized]) {
687
+ sections[normalized] = currentLines.join("\n").trim();
688
+ }
413
689
  }
414
- if (!agentsExists) {
415
- issues.push("AGENTS.md missing.");
690
+
691
+ for (const line of lines) {
692
+ const heading = line.match(/^#{1,6}\s+(.+?)\s*$/);
693
+ if (heading) {
694
+ flush();
695
+ currentHeading = heading[1];
696
+ currentLines = [];
697
+ continue;
698
+ }
699
+
700
+ if (currentHeading) {
701
+ currentLines.push(line);
702
+ }
416
703
  }
417
704
 
418
- if (agentsExists) {
419
- const hasBegin = agents.includes(MARKER_BEGIN);
420
- const hasEnd = agents.includes(MARKER_END);
421
- if (!hasBegin || !hasEnd) {
422
- issues.push("AGENTS.md missing rules-doctor markers.");
705
+ flush();
706
+ return sections;
707
+ }
708
+
709
+ function pickFirstNonEmptyLine(text) {
710
+ for (const line of text.split("\n")) {
711
+ const trimmed = line.trim();
712
+ if (trimmed) {
713
+ return trimmed;
423
714
  }
424
715
  }
716
+ return "";
717
+ }
425
718
 
426
- if (claudeExists && !hasVerifyCommand(claude)) {
427
- issues.push("CLAUDE.md appears to be missing verify commands (lint/test/build).");
719
+ function parseListItems(text) {
720
+ const items = [];
721
+ for (const line of text.split("\n")) {
722
+ const bullet = line.match(/^\s*[-*]\s+(.+?)\s*$/);
723
+ if (bullet) {
724
+ items.push(bullet[1].trim());
725
+ continue;
726
+ }
727
+ const numbered = line.match(/^\s*\d+\.\s+(.+?)\s*$/);
728
+ if (numbered) {
729
+ items.push(numbered[1].trim());
730
+ }
428
731
  }
732
+ return items;
733
+ }
429
734
 
430
- if (agentsExists && !hasVerifyCommand(agents)) {
431
- issues.push("AGENTS.md appears to be missing verify commands (lint/test/build).");
735
+ function unquoteValue(value) {
736
+ let current = value.trim();
737
+ if (current.startsWith("`") && current.endsWith("`")) {
738
+ current = current.slice(1, -1);
432
739
  }
740
+ current = stripQuotes(current);
741
+ return current.trim();
742
+ }
433
743
 
434
- if (
435
- (hasNoApprovalLanguage(claude) && hasAskApprovalLanguage(agents)) ||
436
- (hasAskApprovalLanguage(claude) && hasNoApprovalLanguage(agents))
437
- ) {
438
- issues.push("Potential contradiction: approval guidance differs between CLAUDE.md and AGENTS.md.");
744
+ function importCommandsFromText(text, commands) {
745
+ const merged = { ...commands };
746
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
747
+ const commandNames = Object.keys(merged);
748
+
749
+ for (const raw of lines) {
750
+ const line = raw.trim();
751
+ for (const name of commandNames) {
752
+ const direct = line.match(new RegExp(`^(?:[-*]\\s*)?${name}\\s*:\\s*(.+)$`, "i"));
753
+ if (direct && direct[1]) {
754
+ merged[name] = unquoteValue(direct[1]);
755
+ }
756
+ }
439
757
  }
440
758
 
441
- if (
442
- (hasRequireTestsLanguage(claude) && hasSkipTestsLanguage(agents)) ||
443
- (hasSkipTestsLanguage(claude) && hasRequireTestsLanguage(agents))
444
- ) {
445
- issues.push("Potential contradiction: test guidance differs between CLAUDE.md and AGENTS.md.");
759
+ for (const name of commandNames) {
760
+ if (!merged[name] || merged[name].includes("TODO")) {
761
+ const found = text.match(new RegExp(`\\b(?:npm run|pnpm|yarn|bun)\\s+${name}\\b`, "i"));
762
+ if (found) {
763
+ merged[name] = found[0];
764
+ }
765
+ }
446
766
  }
447
767
 
448
- console.log("rules-doctor analyze");
449
- console.log(`- CLAUDE.md: ${claudeExists ? "found" : "missing"}`);
450
- console.log(`- AGENTS.md: ${agentsExists ? "found" : "missing"}`);
451
- console.log("- Findings:");
768
+ return merged;
769
+ }
452
770
 
453
- if (issues.length === 0) {
454
- console.log("- No obvious issues found.");
455
- return;
771
+ function getSectionText(sections, aliases) {
772
+ for (const alias of aliases) {
773
+ const key = normalizeHeading(alias);
774
+ if (sections[key] && sections[key].trim()) {
775
+ return sections[key].trim();
776
+ }
456
777
  }
778
+ return "";
779
+ }
457
780
 
458
- for (const issue of issues) {
459
- console.log(`- ${issue}`);
781
+ function collectImportSources(rootDir) {
782
+ const candidates = [
783
+ { id: "claude", path: "CLAUDE.md" },
784
+ { id: "claude-local", path: ".claude/CLAUDE.md" },
785
+ { id: "codex", path: "AGENTS.md" },
786
+ { id: "copilot", path: ".github/copilot-instructions.md" },
787
+ { id: "gemini", path: "GEMINI.md" },
788
+ { id: "cursor", path: ".cursor/rules/rules-doctor.mdc" },
789
+ ];
790
+
791
+ const uniquePaths = new Set();
792
+ const sources = [];
793
+
794
+ for (const item of candidates) {
795
+ if (uniquePaths.has(item.path)) {
796
+ continue;
797
+ }
798
+ uniquePaths.add(item.path);
799
+
800
+ const absolutePath = resolveInRoot(rootDir, item.path);
801
+ if (!existsSync(absolutePath)) {
802
+ continue;
803
+ }
804
+ sources.push({
805
+ id: item.id,
806
+ path: item.path,
807
+ absolutePath,
808
+ text: readFileSync(absolutePath, "utf8"),
809
+ });
460
810
  }
811
+
812
+ return sources;
461
813
  }
462
814
 
463
- function parseSyncTarget(args) {
464
- let target = "all";
815
+ function importRulesFromDocs(rootDir, defaults) {
816
+ const imported = JSON.parse(JSON.stringify(defaults));
817
+ const sources = collectImportSources(rootDir);
818
+ const notes = [];
819
+
820
+ if (sources.length === 0) {
821
+ return {
822
+ rules: imported,
823
+ report: "No existing docs were found. Created default rules.",
824
+ sources,
825
+ };
826
+ }
465
827
 
466
- for (let index = 0; index < args.length; index += 1) {
467
- const arg = args[index];
468
- if (arg === "--target") {
469
- const value = args[index + 1];
470
- if (value !== "all" && value !== "claude" && value !== "codex") {
471
- throw new Error('Invalid --target value. Use one of: "all", "claude", "codex".');
828
+ notes.push(`Found ${sources.length} source file(s):`);
829
+ for (const source of sources) {
830
+ notes.push(`- ${source.path}`);
831
+ }
832
+
833
+ for (const source of sources) {
834
+ const sections = parseMarkdownSections(source.text);
835
+
836
+ const missionSection = getSectionText(sections, ["mission"]);
837
+ if (missionSection) {
838
+ const mission = pickFirstNonEmptyLine(missionSection);
839
+ if (mission) {
840
+ imported.mission = mission;
472
841
  }
473
- target = value;
474
- index += 1;
842
+ }
843
+
844
+ const workflowSection = getSectionText(sections, ["workflow", "operational loop"]);
845
+ const workflow = parseListItems(workflowSection);
846
+ if (workflow.length > 0) {
847
+ imported.workflow = workflow;
848
+ }
849
+
850
+ const doneSection = getSectionText(sections, ["done", "done criteria"]);
851
+ const done = parseListItems(doneSection);
852
+ if (done.length > 0) {
853
+ imported.done = done;
854
+ }
855
+
856
+ const approvalsSection = getSectionText(sections, ["approvals", "approval"]);
857
+ if (approvalsSection) {
858
+ const mode = approvalsSection.match(/(?:mode|policy)\s*:\s*`?([a-z0-9_-]+)`?/i);
859
+ if (mode && mode[1]) {
860
+ imported.approvals.mode = mode[1].trim();
861
+ }
862
+ const approvalNotes = parseListItems(approvalsSection);
863
+ if (approvalNotes.length > 0) {
864
+ imported.approvals.notes = approvalNotes;
865
+ }
866
+ }
867
+
868
+ imported.commands = importCommandsFromText(source.text, imported.commands);
869
+ }
870
+
871
+ for (const adapter of ADAPTERS) {
872
+ const config = getTargetConfig(imported, adapter);
873
+ const absolutePath = resolveInRoot(rootDir, config.path);
874
+ if (existsSync(absolutePath)) {
875
+ imported.targets[adapter.id].enabled = true;
876
+ }
877
+ }
878
+
879
+ notes.push("Imported mission/workflow/commands/done/approvals where detected.");
880
+ return {
881
+ rules: imported,
882
+ report: notes.join("\n"),
883
+ sources,
884
+ };
885
+ }
886
+
887
+ function initCommand(rootDir, logger, args) {
888
+ const options = parseInitArgs(args);
889
+ const rulesFile = resolve(rootDir, RULES_RELATIVE_PATH);
890
+ if (existsSync(rulesFile)) {
891
+ logger.log(`rules.yaml already exists: ${rulesFile}`);
892
+ return 0;
893
+ }
894
+
895
+ const defaults = createDefaultRules(loadPackageScripts(rootDir));
896
+ let rules = defaults;
897
+ let importReport = "";
898
+
899
+ if (options.importExisting) {
900
+ const imported = importRulesFromDocs(rootDir, defaults);
901
+ rules = imported.rules;
902
+ importReport = imported.report;
903
+ }
904
+
905
+ rules = applyTargetPreset(rules, options.preset);
906
+
907
+ ensureParentDirectory(rulesFile);
908
+ writeFileSync(rulesFile, stringifyRules(rules), "utf8");
909
+ logger.log(`Created ${rulesFile}`);
910
+ logger.log(`Applied target preset: ${options.preset}`);
911
+
912
+ if (options.importExisting) {
913
+ const reportPath = resolve(rootDir, IMPORT_REPORT_RELATIVE_PATH);
914
+ const reportContent =
915
+ options.preset === "all"
916
+ ? `${importReport}\n`
917
+ : `${importReport}\n\nApplied target preset: ${options.preset}\n`;
918
+ writeFileSync(reportPath, reportContent, "utf8");
919
+ logger.log(`Import report: ${reportPath}`);
920
+ }
921
+
922
+ return 0;
923
+ }
924
+
925
+ function presetApplyCommand(rootDir, logger, args) {
926
+ const options = parsePresetArgs(args);
927
+ const { rules, rulesFile } = loadRules(rootDir);
928
+ const nextRules = applyTargetPreset(
929
+ {
930
+ ...rules,
931
+ targets: { ...(rules.targets || {}) },
932
+ },
933
+ options.presetName,
934
+ );
935
+
936
+ const currentText = stringifyRules(rules);
937
+ const nextText = stringifyRules(nextRules);
938
+ const changed = currentText !== nextText;
939
+
940
+ logger.log("rules-doctor preset apply");
941
+ logger.log(`- root: ${rootDir}`);
942
+ logger.log(`- preset: ${options.presetName}`);
943
+ logger.log(`- mode: ${options.write ? "write" : "dry-run"}`);
944
+
945
+ if (options.diff && changed) {
946
+ logger.log("\n# diff: .agentrules/rules.yaml");
947
+ logger.log(renderSimpleDiff(currentText, nextText));
948
+ }
949
+
950
+ if (!changed) {
951
+ logger.log("No changes required: preset already applied.");
952
+ return 0;
953
+ }
954
+
955
+ if (!options.write) {
956
+ logger.log("Dry-run complete: rules.yaml would be updated. Re-run with --write.");
957
+ return 0;
958
+ }
959
+
960
+ ensureParentDirectory(rulesFile);
961
+ writeFileSync(rulesFile, nextText, "utf8");
962
+ logger.log(`Updated ${rulesFile}`);
963
+ return 0;
964
+ }
965
+
966
+ function buildTargetPlans(rootDir, rules, targetIds) {
967
+ const plans = [];
968
+
969
+ for (const targetId of targetIds) {
970
+ const adapter = ADAPTERS_BY_ID[targetId];
971
+ const target = getTargetConfig(rules, adapter);
972
+ const targetPath = resolveInRoot(rootDir, target.path);
973
+ const fileExists = existsSync(targetPath);
974
+ const currentText = fileExists ? readFileSync(targetPath, "utf8") : "";
975
+
976
+ if (!target.enabled) {
977
+ plans.push({
978
+ targetId,
979
+ adapter,
980
+ enabled: false,
981
+ targetPath,
982
+ targetPathDisplay: target.path,
983
+ exists: fileExists,
984
+ currentText,
985
+ desiredText: currentText,
986
+ changed: false,
987
+ });
475
988
  continue;
476
989
  }
477
990
 
478
- throw new Error(`Unknown option for sync: ${arg}`);
991
+ const rendered = adapter.render(rules).trim();
992
+ const desiredText =
993
+ adapter.management === "marker"
994
+ ? upsertManagedSection(currentText, rendered, adapter.markerBegin, adapter.markerEnd)
995
+ : `${rendered}\n`;
996
+
997
+ plans.push({
998
+ targetId,
999
+ adapter,
1000
+ enabled: true,
1001
+ targetPath,
1002
+ targetPathDisplay: target.path,
1003
+ exists: fileExists,
1004
+ currentText,
1005
+ desiredText,
1006
+ changed: desiredText !== currentText,
1007
+ });
479
1008
  }
480
1009
 
481
- return target;
1010
+ return plans;
482
1011
  }
483
1012
 
484
- function main() {
485
- const [command, ...args] = process.argv.slice(2);
1013
+ function renderSimpleDiff(currentText, desiredText) {
1014
+ if (currentText === desiredText) {
1015
+ return "";
1016
+ }
1017
+
1018
+ const oldLines = currentText.replace(/\r\n/g, "\n").split("\n");
1019
+ const newLines = desiredText.replace(/\r\n/g, "\n").split("\n");
1020
+ const output = ["--- current", "+++ desired"];
1021
+ const maxLines = Math.max(oldLines.length, newLines.length);
1022
+ const hardLimit = 120;
1023
+ let emitted = 0;
1024
+
1025
+ for (let index = 0; index < maxLines; index += 1) {
1026
+ const oldLine = oldLines[index];
1027
+ const newLine = newLines[index];
1028
+
1029
+ if (oldLine === newLine) {
1030
+ continue;
1031
+ }
1032
+
1033
+ if (typeof oldLine !== "undefined") {
1034
+ output.push(`-${oldLine}`);
1035
+ emitted += 1;
1036
+ }
1037
+ if (typeof newLine !== "undefined") {
1038
+ output.push(`+${newLine}`);
1039
+ emitted += 1;
1040
+ }
486
1041
 
487
- if (!command || command === "--help" || command === "-h") {
488
- console.log(usage());
489
- return;
1042
+ if (emitted >= hardLimit) {
1043
+ output.push("... diff truncated ...");
1044
+ break;
1045
+ }
490
1046
  }
491
1047
 
492
- if (command === "init") {
493
- initCommand();
494
- return;
1048
+ return output.join("\n");
1049
+ }
1050
+
1051
+ function formatPlanSummary(plans) {
1052
+ const changed = plans.filter((plan) => plan.changed).length;
1053
+ const changedFiles = new Set(plans.filter((plan) => plan.changed).map((plan) => plan.targetPath)).size;
1054
+ const enabled = plans.filter((plan) => plan.enabled).length;
1055
+ const disabled = plans.length - enabled;
1056
+ return { changed, changedFiles, enabled, disabled };
1057
+ }
1058
+
1059
+ function getUniqueWritePlans(plans) {
1060
+ const byPath = new Map();
1061
+ const duplicates = [];
1062
+
1063
+ for (const plan of plans) {
1064
+ if (!plan.enabled || !plan.changed) {
1065
+ continue;
1066
+ }
1067
+
1068
+ const existing = byPath.get(plan.targetPath);
1069
+ if (!existing) {
1070
+ byPath.set(plan.targetPath, plan);
1071
+ continue;
1072
+ }
1073
+
1074
+ if (existing.desiredText !== plan.desiredText) {
1075
+ throw new Error(
1076
+ `Conflicting outputs for ${plan.targetPathDisplay}: ${existing.targetId} and ${plan.targetId} produce different content.`,
1077
+ );
1078
+ }
1079
+
1080
+ duplicates.push({
1081
+ targetPathDisplay: plan.targetPathDisplay,
1082
+ winner: existing.targetId,
1083
+ duplicate: plan.targetId,
1084
+ });
495
1085
  }
496
1086
 
497
- if (command === "sync") {
498
- syncCommand(parseSyncTarget(args));
499
- return;
1087
+ return {
1088
+ uniquePlans: Array.from(byPath.values()),
1089
+ duplicates,
1090
+ };
1091
+ }
1092
+
1093
+ function syncCommand(rootDir, logger, args) {
1094
+ const options = parseTargetedArgs("sync", args, { write: true, backup: true });
1095
+ const { rules } = loadRules(rootDir);
1096
+ const plans = buildTargetPlans(rootDir, rules, options.targetIds);
1097
+ const summary = formatPlanSummary(plans);
1098
+
1099
+ logger.log("rules-doctor sync");
1100
+ logger.log(`- root: ${rootDir}`);
1101
+ logger.log(`- selected targets: ${options.targetIds.join(", ")}`);
1102
+ logger.log(`- mode: ${options.write ? "write" : "dry-run"}`);
1103
+
1104
+ for (const plan of plans) {
1105
+ if (!plan.enabled) {
1106
+ logger.log(`- ${plan.targetId}: disabled (${plan.targetPathDisplay})`);
1107
+ continue;
1108
+ }
1109
+ if (!plan.changed) {
1110
+ logger.log(`- ${plan.targetId}: up-to-date (${plan.targetPathDisplay})`);
1111
+ continue;
1112
+ }
1113
+ logger.log(`- ${plan.targetId}: would update (${plan.targetPathDisplay})`);
1114
+ }
1115
+
1116
+ if (options.diff) {
1117
+ for (const plan of plans) {
1118
+ if (!plan.enabled || !plan.changed) {
1119
+ continue;
1120
+ }
1121
+ logger.log(`\n# diff: ${plan.targetId} (${plan.targetPathDisplay})`);
1122
+ logger.log(renderSimpleDiff(plan.currentText, plan.desiredText));
1123
+ }
1124
+ }
1125
+
1126
+ if (!options.write) {
1127
+ if (summary.changed === 0) {
1128
+ logger.log("Dry-run complete: no changes.");
1129
+ } else {
1130
+ logger.log(
1131
+ `Dry-run complete: ${summary.changedFiles} file(s) would change (${summary.changed} target mappings). Re-run with --write.`,
1132
+ );
1133
+ }
1134
+ return 0;
1135
+ }
1136
+
1137
+ const { uniquePlans, duplicates } = getUniqueWritePlans(plans);
1138
+ for (const duplicate of duplicates) {
1139
+ logger.log(
1140
+ ` note: ${duplicate.duplicate} shares output with ${duplicate.winner} at ${duplicate.targetPathDisplay}`,
1141
+ );
1142
+ }
1143
+
1144
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1145
+ for (const plan of uniquePlans) {
1146
+ ensureParentDirectory(plan.targetPath);
1147
+ if (options.backup && plan.exists) {
1148
+ const backupPath = `${plan.targetPath}.rules-doctor.bak.${timestamp}`;
1149
+ writeFileSync(backupPath, plan.currentText, "utf8");
1150
+ logger.log(` backup: ${backupPath}`);
1151
+ }
1152
+
1153
+ writeFileSync(plan.targetPath, plan.desiredText, "utf8");
1154
+ logger.log(` updated: ${plan.targetPath}`);
1155
+ }
1156
+
1157
+ logger.log(
1158
+ `Write complete: ${uniquePlans.length} file(s) updated (${summary.changed} target mappings changed).`,
1159
+ );
1160
+ return 0;
1161
+ }
1162
+
1163
+ function checkCommand(rootDir, logger, args) {
1164
+ const options = parseTargetedArgs("check", args, { write: false, backup: false });
1165
+ const { rules } = loadRules(rootDir);
1166
+ const plans = buildTargetPlans(rootDir, rules, options.targetIds);
1167
+ const summary = formatPlanSummary(plans);
1168
+
1169
+ logger.log("rules-doctor check");
1170
+ logger.log(`- root: ${rootDir}`);
1171
+ logger.log(`- selected targets: ${options.targetIds.join(", ")}`);
1172
+
1173
+ for (const plan of plans) {
1174
+ if (!plan.enabled) {
1175
+ logger.log(`- ${plan.targetId}: disabled (${plan.targetPathDisplay})`);
1176
+ continue;
1177
+ }
1178
+ logger.log(
1179
+ `- ${plan.targetId}: ${plan.changed ? "drift detected" : "in sync"} (${plan.targetPathDisplay})`,
1180
+ );
1181
+ }
1182
+
1183
+ if (options.diff) {
1184
+ for (const plan of plans) {
1185
+ if (!plan.enabled || !plan.changed) {
1186
+ continue;
1187
+ }
1188
+ logger.log(`\n# diff: ${plan.targetId} (${plan.targetPathDisplay})`);
1189
+ logger.log(renderSimpleDiff(plan.currentText, plan.desiredText));
1190
+ }
1191
+ }
1192
+
1193
+ if (summary.changed === 0) {
1194
+ logger.log("Check complete: all selected targets are in sync.");
1195
+ return 0;
1196
+ }
1197
+
1198
+ logger.log(`Check failed: ${summary.changed} target file(s) need sync.`);
1199
+ return 1;
1200
+ }
1201
+
1202
+ function analyzeCommand(rootDir, logger, args) {
1203
+ const options = parseAnalyzeArgs(args);
1204
+ const { rules, rulesExists, rulesFile } = loadRules(rootDir, { allowMissing: true });
1205
+ const issues = [];
1206
+ const targetSnapshots = [];
1207
+
1208
+ logger.log("rules-doctor analyze");
1209
+ logger.log(`- rules.yaml: ${rulesExists ? "found" : "missing (using defaults)"}`);
1210
+ logger.log(`- rules path: ${rulesFile}`);
1211
+
1212
+ for (const adapter of ADAPTERS) {
1213
+ const target = getTargetConfig(rules, adapter);
1214
+ const absolutePath = resolveInRoot(rootDir, target.path);
1215
+ const fileExists = existsSync(absolutePath);
1216
+ const content = fileExists ? readFileSync(absolutePath, "utf8") : "";
1217
+
1218
+ logger.log(
1219
+ `- target ${adapter.id}: ${target.enabled ? "enabled" : "disabled"}, ${
1220
+ fileExists ? "found" : "missing"
1221
+ } (${target.path})`,
1222
+ );
1223
+
1224
+ if (!target.enabled) {
1225
+ continue;
1226
+ }
1227
+
1228
+ if (!fileExists) {
1229
+ issues.push(`${adapter.id}: expected file is missing (${target.path}).`);
1230
+ continue;
1231
+ }
1232
+
1233
+ if (adapter.management === "marker") {
1234
+ const hasBegin = content.includes(adapter.markerBegin);
1235
+ const hasEnd = content.includes(adapter.markerEnd);
1236
+ if (!hasBegin || !hasEnd) {
1237
+ issues.push(`${adapter.id}: marker block is missing.`);
1238
+ }
1239
+ }
1240
+
1241
+ if (!hasVerifyCommand(content)) {
1242
+ issues.push(`${adapter.id}: verify commands (lint/test/build) not detected.`);
1243
+ }
1244
+
1245
+ targetSnapshots.push({
1246
+ id: adapter.id,
1247
+ content,
1248
+ asksApproval: hasAskApprovalLanguage(content),
1249
+ noApproval: hasNoApprovalLanguage(content),
1250
+ requiresTests: hasRequireTestsLanguage(content),
1251
+ skipsTests: hasSkipTestsLanguage(content),
1252
+ });
1253
+ }
1254
+
1255
+ const askApprovalTargets = targetSnapshots.filter((item) => item.asksApproval).map((item) => item.id);
1256
+ const noApprovalTargets = targetSnapshots.filter((item) => item.noApproval).map((item) => item.id);
1257
+ if (askApprovalTargets.length > 0 && noApprovalTargets.length > 0) {
1258
+ issues.push(
1259
+ `Potential contradiction: approval guidance differs (${askApprovalTargets.join(
1260
+ ", ",
1261
+ )} vs ${noApprovalTargets.join(", ")}).`,
1262
+ );
1263
+ }
1264
+
1265
+ const requireTestsTargets = targetSnapshots
1266
+ .filter((item) => item.requiresTests)
1267
+ .map((item) => item.id);
1268
+ const skipTestsTargets = targetSnapshots.filter((item) => item.skipsTests).map((item) => item.id);
1269
+ if (requireTestsTargets.length > 0 && skipTestsTargets.length > 0) {
1270
+ issues.push(
1271
+ `Potential contradiction: test guidance differs (${requireTestsTargets.join(
1272
+ ", ",
1273
+ )} vs ${skipTestsTargets.join(", ")}).`,
1274
+ );
1275
+ }
1276
+
1277
+ logger.log("- Findings:");
1278
+ if (issues.length === 0) {
1279
+ logger.log("- No obvious issues found.");
1280
+ return 0;
1281
+ }
1282
+
1283
+ for (const issue of issues) {
1284
+ logger.log(`- ${issue}`);
1285
+ }
1286
+
1287
+ if (options.strict) {
1288
+ throw new Error(`Analyze failed in strict mode with ${issues.length} issue(s).`);
1289
+ }
1290
+
1291
+ return 0;
1292
+ }
1293
+
1294
+ function doctorCommand(rootDir, logger, args) {
1295
+ const options = parseDoctorArgs(args);
1296
+ const { rules, rulesExists, rulesFile } = loadRules(rootDir, { allowMissing: true });
1297
+ const issues = [];
1298
+
1299
+ logger.log("rules-doctor doctor");
1300
+ logger.log(`- root: ${rootDir}`);
1301
+ logger.log(`- rules file: ${rulesFile}`);
1302
+ logger.log(`- rules exists: ${rulesExists ? "yes" : "no (defaults assumed)"}`);
1303
+
1304
+ const pathToTargets = {};
1305
+ for (const adapter of ADAPTERS) {
1306
+ const target = getTargetConfig(rules, adapter);
1307
+ const absolutePath = resolveInRoot(rootDir, target.path);
1308
+ const exists = existsSync(absolutePath);
1309
+ const enabledText = target.enabled ? "enabled" : "disabled";
1310
+ logger.log(`- ${adapter.id}: ${enabledText}, path=${target.path}, file=${exists ? "found" : "missing"}`);
1311
+
1312
+ if (!target.enabled) {
1313
+ continue;
1314
+ }
1315
+
1316
+ const key = absolutePath;
1317
+ if (!pathToTargets[key]) {
1318
+ pathToTargets[key] = [];
1319
+ }
1320
+ pathToTargets[key].push(adapter.id);
1321
+ }
1322
+
1323
+ for (const path of Object.keys(pathToTargets)) {
1324
+ const ids = pathToTargets[path];
1325
+ if (ids.length > 1) {
1326
+ issues.push(`Multiple enabled targets map to the same file: ${ids.join(", ")} -> ${path}`);
1327
+ }
1328
+ }
1329
+
1330
+ logger.log("- Findings:");
1331
+ if (issues.length === 0) {
1332
+ logger.log("- No structural issues found.");
1333
+ return 0;
1334
+ }
1335
+ for (const issue of issues) {
1336
+ logger.log(`- ${issue}`);
500
1337
  }
501
1338
 
502
- if (command === "analyze") {
503
- analyzeCommand();
504
- return;
1339
+ if (options.strict) {
1340
+ return 1;
505
1341
  }
1342
+ return 0;
1343
+ }
1344
+
1345
+ function targetsListCommand(logger) {
1346
+ logger.log("Supported targets:");
1347
+ for (const adapter of ADAPTERS) {
1348
+ const mode = adapter.management === "marker" ? "marker-managed" : "full-managed";
1349
+ logger.log(`- ${adapter.id}: ${adapter.name}`);
1350
+ logger.log(` default path: ${adapter.defaultPath}`);
1351
+ logger.log(` mode: ${mode}`);
1352
+ logger.log(` ${adapter.description}`);
1353
+ }
1354
+ }
1355
+
1356
+ function runCli(argv, options) {
1357
+ const args = Array.isArray(argv) ? argv : [];
1358
+ const logger = createLogger(options || {});
1359
+ const cwd = resolve(options && options.cwd ? options.cwd : process.cwd());
1360
+ const rootDir = findProjectRoot(cwd);
1361
+
1362
+ try {
1363
+ const [command, ...rest] = args;
1364
+
1365
+ if (!command || command === "--help" || command === "-h") {
1366
+ logger.log(usage());
1367
+ return 0;
1368
+ }
1369
+
1370
+ if (command === "init") {
1371
+ return initCommand(rootDir, logger, rest);
1372
+ }
506
1373
 
507
- throw new Error(`Unknown command: ${command}\n\n${usage()}`);
1374
+ if (command === "preset") {
1375
+ return presetApplyCommand(rootDir, logger, rest);
1376
+ }
1377
+
1378
+ if (command === "sync") {
1379
+ return syncCommand(rootDir, logger, rest);
1380
+ }
1381
+
1382
+ if (command === "check") {
1383
+ return checkCommand(rootDir, logger, rest);
1384
+ }
1385
+
1386
+ if (command === "analyze") {
1387
+ return analyzeCommand(rootDir, logger, rest);
1388
+ }
1389
+
1390
+ if (command === "doctor") {
1391
+ return doctorCommand(rootDir, logger, rest);
1392
+ }
1393
+
1394
+ if (command === "targets") {
1395
+ if (rest.length === 1 && rest[0] === "list") {
1396
+ targetsListCommand(logger);
1397
+ return 0;
1398
+ }
1399
+ throw new Error('Unknown targets command. Use "rules-doctor targets list".');
1400
+ }
1401
+
1402
+ throw new Error(`Unknown command: ${command}\n\n${usage()}`);
1403
+ } catch (error) {
1404
+ const message = error instanceof Error ? error.message : String(error);
1405
+ logger.error(`Error: ${message}`);
1406
+ return 1;
1407
+ }
508
1408
  }
509
1409
 
510
- try {
511
- main();
512
- } catch (error) {
513
- const message = error instanceof Error ? error.message : String(error);
514
- console.error(`Error: ${message}`);
515
- process.exitCode = 1;
1410
+ if (require.main === module) {
1411
+ process.exitCode = runCli(process.argv.slice(2));
516
1412
  }
517
1413
 
1414
+ module.exports = {
1415
+ ADAPTERS,
1416
+ createDefaultRules,
1417
+ normalizeRules,
1418
+ parseRulesText,
1419
+ runCli,
1420
+ stringifyRules,
1421
+ };