@jazpiper/rules-doctor 0.3.0 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +1 -42
  2. package/dist/index.js +774 -438
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,26 +1,29 @@
1
1
  #!/usr/bin/env node
2
- const { dirname, isAbsolute, resolve } = require("node:path");
3
- const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs");
2
+ const { dirname, isAbsolute, relative, resolve } = require("node:path");
3
+ const { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs");
4
4
  const { ADAPTERS, ADAPTERS_BY_ID } = require("./adapters");
5
5
 
6
6
  const RULES_RELATIVE_PATH = ".agentrules/rules.yaml";
7
7
  const IMPORT_REPORT_RELATIVE_PATH = ".agentrules/import-report.md";
8
- const PRESET_NAMES = ["all", "core", "copilot"];
8
+ const REQUIRED_RULE_KEYS = [
9
+ "version",
10
+ "mission",
11
+ "workflow",
12
+ "commands",
13
+ "done",
14
+ "approvals",
15
+ "targets",
16
+ ];
9
17
 
10
18
  function usage() {
11
19
  const targets = ADAPTERS.map((adapter) => adapter.id).join("|");
12
- const presets = PRESET_NAMES.join("|");
13
20
  return [
14
21
  "rules-doctor",
15
22
  "",
16
23
  "Usage:",
17
- ` rules-doctor init [--import] [--preset ${presets}]`,
18
- ` rules-doctor preset apply <${presets}> [--diff] [--write]`,
24
+ " rules-doctor init [--import]",
19
25
  ` rules-doctor sync [--target all|${targets}|<comma-separated-targets>] [--diff] [--write] [--backup]`,
20
26
  ` 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
27
  "",
25
28
  "Notes:",
26
29
  " - sync defaults to dry-run. Add --write to apply changes.",
@@ -54,12 +57,12 @@ function readJsonFile(filePath) {
54
57
 
55
58
  function stripQuotes(value) {
56
59
  const trimmed = value.trim();
57
- if (
58
- (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
59
- (trimmed.startsWith("'") && trimmed.endsWith("'"))
60
- ) {
60
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
61
+ return trimmed.slice(1, -1).replace(/''/g, "'");
62
+ }
63
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
61
64
  try {
62
- return JSON.parse(trimmed.replace(/'/g, '"'));
65
+ return JSON.parse(trimmed);
63
66
  } catch {
64
67
  return trimmed.slice(1, -1);
65
68
  }
@@ -67,8 +70,285 @@ function stripQuotes(value) {
67
70
  return trimmed;
68
71
  }
69
72
 
73
+ function stripInlineComment(value) {
74
+ const input = String(value);
75
+ let inSingle = false;
76
+ let inDouble = false;
77
+ let escaped = false;
78
+
79
+ for (let index = 0; index < input.length; index += 1) {
80
+ const char = input[index];
81
+
82
+ if (escaped) {
83
+ escaped = false;
84
+ continue;
85
+ }
86
+
87
+ if (char === "\\" && inDouble) {
88
+ escaped = true;
89
+ continue;
90
+ }
91
+
92
+ if (char === "'" && !inDouble) {
93
+ if (inSingle && input[index + 1] === "'") {
94
+ index += 1;
95
+ continue;
96
+ }
97
+ inSingle = !inSingle;
98
+ continue;
99
+ }
100
+
101
+ if (char === '"' && !inSingle) {
102
+ inDouble = !inDouble;
103
+ continue;
104
+ }
105
+
106
+ if (char === "#" && !inSingle && !inDouble) {
107
+ const previous = index > 0 ? input[index - 1] : " ";
108
+ if (/\s/.test(previous)) {
109
+ return input.slice(0, index).trimEnd();
110
+ }
111
+ }
112
+ }
113
+
114
+ return input.trimEnd();
115
+ }
116
+
117
+ function splitTopLevel(value, delimiter) {
118
+ const input = String(value);
119
+ const parts = [];
120
+ let inSingle = false;
121
+ let inDouble = false;
122
+ let escaped = false;
123
+ let braceDepth = 0;
124
+ let bracketDepth = 0;
125
+ let parenDepth = 0;
126
+ let last = 0;
127
+
128
+ for (let index = 0; index < input.length; index += 1) {
129
+ const char = input[index];
130
+
131
+ if (escaped) {
132
+ escaped = false;
133
+ continue;
134
+ }
135
+
136
+ if (char === "\\" && inDouble) {
137
+ escaped = true;
138
+ continue;
139
+ }
140
+
141
+ if (char === "'" && !inDouble) {
142
+ if (inSingle && input[index + 1] === "'") {
143
+ index += 1;
144
+ continue;
145
+ }
146
+ inSingle = !inSingle;
147
+ continue;
148
+ }
149
+
150
+ if (char === '"' && !inSingle) {
151
+ inDouble = !inDouble;
152
+ continue;
153
+ }
154
+
155
+ if (inSingle || inDouble) {
156
+ continue;
157
+ }
158
+
159
+ if (char === "{") {
160
+ braceDepth += 1;
161
+ continue;
162
+ }
163
+ if (char === "}") {
164
+ braceDepth = Math.max(0, braceDepth - 1);
165
+ continue;
166
+ }
167
+ if (char === "[") {
168
+ bracketDepth += 1;
169
+ continue;
170
+ }
171
+ if (char === "]") {
172
+ bracketDepth = Math.max(0, bracketDepth - 1);
173
+ continue;
174
+ }
175
+ if (char === "(") {
176
+ parenDepth += 1;
177
+ continue;
178
+ }
179
+ if (char === ")") {
180
+ parenDepth = Math.max(0, parenDepth - 1);
181
+ continue;
182
+ }
183
+
184
+ if (char === delimiter && braceDepth === 0 && bracketDepth === 0 && parenDepth === 0) {
185
+ parts.push(input.slice(last, index));
186
+ last = index + 1;
187
+ }
188
+ }
189
+
190
+ parts.push(input.slice(last));
191
+ return parts;
192
+ }
193
+
194
+ function splitKeyValueLine(line, options) {
195
+ const opts = options || {};
196
+ const input = String(line);
197
+ let inSingle = false;
198
+ let inDouble = false;
199
+ let escaped = false;
200
+
201
+ for (let index = 0; index < input.length; index += 1) {
202
+ const char = input[index];
203
+
204
+ if (escaped) {
205
+ escaped = false;
206
+ continue;
207
+ }
208
+
209
+ if (char === "\\" && inDouble) {
210
+ escaped = true;
211
+ continue;
212
+ }
213
+
214
+ if (char === "'" && !inDouble) {
215
+ if (inSingle && input[index + 1] === "'") {
216
+ index += 1;
217
+ continue;
218
+ }
219
+ inSingle = !inSingle;
220
+ continue;
221
+ }
222
+
223
+ if (char === '"' && !inSingle) {
224
+ inDouble = !inDouble;
225
+ continue;
226
+ }
227
+
228
+ if (char === ":" && !inSingle && !inDouble) {
229
+ const next = input[index + 1];
230
+ if (!opts.allowTightValue && typeof next !== "undefined" && !/\s/.test(next)) {
231
+ continue;
232
+ }
233
+ const key = stripQuotes(input.slice(0, index).trim());
234
+ if (!key) {
235
+ return null;
236
+ }
237
+ return [key, input.slice(index + 1)];
238
+ }
239
+ }
240
+
241
+ return null;
242
+ }
243
+
244
+ function parseInlineCollection(value) {
245
+ const input = String(value).trim();
246
+ if (input.startsWith("{") && input.endsWith("}")) {
247
+ const inner = input.slice(1, -1).trim();
248
+ if (!inner) {
249
+ return {};
250
+ }
251
+
252
+ const result = {};
253
+ for (const entry of splitTopLevel(inner, ",")) {
254
+ const item = entry.trim();
255
+ if (!item) {
256
+ continue;
257
+ }
258
+
259
+ const pair = splitKeyValueLine(item, { allowTightValue: true });
260
+ if (!pair) {
261
+ return undefined;
262
+ }
263
+ result[pair[0]] = parseScalar(pair[1]);
264
+ }
265
+ return result;
266
+ }
267
+
268
+ if (input.startsWith("[") && input.endsWith("]")) {
269
+ const inner = input.slice(1, -1).trim();
270
+ if (!inner) {
271
+ return [];
272
+ }
273
+ return splitTopLevel(inner, ",").map((item) => parseScalar(item.trim()));
274
+ }
275
+
276
+ return undefined;
277
+ }
278
+
279
+ function isBlockScalarIndicator(value) {
280
+ const cleaned = stripInlineComment(value).trim();
281
+ return /^[|>][-+]?$/.test(cleaned);
282
+ }
283
+
284
+ function foldBlockLines(lines) {
285
+ let output = "";
286
+ for (const line of lines) {
287
+ if (line === "") {
288
+ output += "\n";
289
+ continue;
290
+ }
291
+ if (output === "" || output.endsWith("\n")) {
292
+ output += line;
293
+ } else {
294
+ output += ` ${line}`;
295
+ }
296
+ }
297
+ return output;
298
+ }
299
+
300
+ function parseBlockScalar(lines, startIndex, baseIndent, indicator) {
301
+ const style = String(indicator).trim().startsWith(">") ? ">" : "|";
302
+ const rawBlock = [];
303
+ let minIndent = Infinity;
304
+ let index = startIndex + 1;
305
+
306
+ while (index < lines.length) {
307
+ const rawLine = lines[index];
308
+ const trimmed = rawLine.trim();
309
+ if (!trimmed) {
310
+ rawBlock.push("");
311
+ index += 1;
312
+ continue;
313
+ }
314
+
315
+ const indent = rawLine.match(/^ */)[0].length;
316
+ if (indent <= baseIndent) {
317
+ break;
318
+ }
319
+
320
+ minIndent = Math.min(minIndent, indent);
321
+ rawBlock.push(rawLine);
322
+ index += 1;
323
+ }
324
+
325
+ if (rawBlock.length === 0) {
326
+ return { value: "", nextIndex: startIndex };
327
+ }
328
+
329
+ const normalized = rawBlock.map((line) => {
330
+ if (!line) {
331
+ return "";
332
+ }
333
+ return line.slice(Math.min(minIndent, line.match(/^ */)[0].length));
334
+ });
335
+
336
+ const value = style === ">" ? foldBlockLines(normalized) : normalized.join("\n");
337
+ return { value: value.trimEnd(), nextIndex: index - 1 };
338
+ }
339
+
70
340
  function parseScalar(value) {
71
- const cleaned = stripQuotes(value);
341
+ const withoutComment = stripInlineComment(value).trim();
342
+ if (!withoutComment) {
343
+ return "";
344
+ }
345
+
346
+ const inlineCollection = parseInlineCollection(withoutComment);
347
+ if (typeof inlineCollection !== "undefined") {
348
+ return inlineCollection;
349
+ }
350
+
351
+ const cleaned = stripQuotes(withoutComment);
72
352
  if (/^(true|false)$/i.test(cleaned)) {
73
353
  return cleaned.toLowerCase() === "true";
74
354
  }
@@ -97,8 +377,10 @@ function parseRulesText(text) {
97
377
  let section = null;
98
378
  let nested = null;
99
379
  let currentTarget = null;
380
+ let targetEntryIndent = null;
100
381
 
101
- for (const rawLine of lines) {
382
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
383
+ const rawLine = lines[lineIndex];
102
384
  if (!rawLine.trim() || rawLine.trim().startsWith("#")) {
103
385
  continue;
104
386
  }
@@ -109,14 +391,15 @@ function parseRulesText(text) {
109
391
  if (indent === 0) {
110
392
  nested = null;
111
393
  currentTarget = null;
394
+ targetEntryIndent = null;
112
395
 
113
- const top = line.match(/^([a-zA-Z0-9_-]+):(.*)$/);
396
+ const top = splitKeyValueLine(line);
114
397
  if (!top) {
115
398
  continue;
116
399
  }
117
400
 
118
- const key = top[1];
119
- const value = top[2].trim();
401
+ const key = top[0];
402
+ const value = stripInlineComment(top[1]).trim();
120
403
 
121
404
  if (!value) {
122
405
  section = key;
@@ -127,7 +410,13 @@ function parseRulesText(text) {
127
410
  }
128
411
  } else {
129
412
  section = null;
130
- data[key] = parseScalar(value);
413
+ if (isBlockScalarIndicator(value)) {
414
+ const parsedBlock = parseBlockScalar(lines, lineIndex, indent, value);
415
+ data[key] = parsedBlock.value;
416
+ lineIndex = parsedBlock.nextIndex;
417
+ } else {
418
+ data[key] = parseScalar(value);
419
+ }
131
420
  }
132
421
  continue;
133
422
  }
@@ -138,20 +427,22 @@ function parseRulesText(text) {
138
427
  }
139
428
 
140
429
  if (section === "commands") {
141
- const pair = line.match(/^([a-zA-Z0-9_-]+):(.*)$/);
430
+ const pair = splitKeyValueLine(line);
142
431
  if (pair) {
143
- data.commands[pair[1]] = parseScalar(pair[2].trim());
432
+ data.commands[pair[0]] = parseScalar(pair[1]);
144
433
  }
145
434
  continue;
146
435
  }
147
436
 
148
437
  if (section === "approvals") {
149
- if (line.startsWith("mode:")) {
150
- data.approvals.mode = parseScalar(line.slice("mode:".length).trim());
438
+ const pair = splitKeyValueLine(line);
439
+
440
+ if (pair && pair[0] === "mode") {
441
+ data.approvals.mode = parseScalar(pair[1]);
151
442
  continue;
152
443
  }
153
444
 
154
- if (line === "notes:") {
445
+ if (pair && pair[0] === "notes" && !stripInlineComment(pair[1]).trim()) {
155
446
  nested = "notes";
156
447
  if (!Array.isArray(data.approvals.notes)) {
157
448
  data.approvals.notes = [];
@@ -166,14 +457,20 @@ function parseRulesText(text) {
166
457
  }
167
458
 
168
459
  if (section === "targets") {
169
- if (indent === 2) {
170
- const target = line.match(/^([a-zA-Z0-9_-]+):(.*)$/);
171
- if (!target) {
172
- continue;
173
- }
460
+ const pair = splitKeyValueLine(line);
461
+ if (!pair) {
462
+ continue;
463
+ }
464
+
465
+ const key = pair[0];
466
+ const maybeValue = stripInlineComment(pair[1]).trim();
467
+
468
+ if (targetEntryIndent === null) {
469
+ targetEntryIndent = indent;
470
+ }
174
471
 
175
- currentTarget = target[1];
176
- const maybeValue = target[2].trim();
472
+ if (indent === targetEntryIndent) {
473
+ currentTarget = key;
177
474
  if (!maybeValue) {
178
475
  data.targets[currentTarget] = {};
179
476
  } else {
@@ -182,14 +479,11 @@ function parseRulesText(text) {
182
479
  continue;
183
480
  }
184
481
 
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());
482
+ if (indent > targetEntryIndent && currentTarget) {
483
+ if (!data.targets[currentTarget] || typeof data.targets[currentTarget] !== "object") {
484
+ data.targets[currentTarget] = {};
192
485
  }
486
+ data.targets[currentTarget][key] = parseScalar(maybeValue);
193
487
  }
194
488
  }
195
489
  }
@@ -248,40 +542,6 @@ function createDefaultRules(scripts) {
248
542
  };
249
543
  }
250
544
 
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,
282
- };
283
- }
284
-
285
545
  function normalizeTargetConfig(source, fallbackPath) {
286
546
  if (typeof source === "string" && source.trim()) {
287
547
  return { enabled: true, path: source.trim() };
@@ -315,6 +575,20 @@ function normalizeRules(input, defaults) {
315
575
  const notes = Array.isArray(approvals.notes)
316
576
  ? approvals.notes.filter((item) => typeof item === "string")
317
577
  : defaults.approvals.notes;
578
+ const normalizedCommands = {};
579
+ for (const [name, value] of Object.entries(commands)) {
580
+ const normalizedName = String(name).trim();
581
+ if (!normalizedName || typeof value !== "string" || !value.trim()) {
582
+ continue;
583
+ }
584
+ normalizedCommands[normalizedName] = value.trim();
585
+ }
586
+
587
+ for (const required of ["lint", "test", "build"]) {
588
+ if (!normalizedCommands[required] && defaults.commands[required]) {
589
+ normalizedCommands[required] = defaults.commands[required];
590
+ }
591
+ }
318
592
 
319
593
  const targets = {};
320
594
  for (const adapter of ADAPTERS) {
@@ -340,11 +614,7 @@ function normalizeRules(input, defaults) {
340
614
  mission:
341
615
  typeof source.mission === "string" && source.mission.trim() ? source.mission : defaults.mission,
342
616
  workflow: workflow.length > 0 ? workflow : defaults.workflow,
343
- commands: {
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,
347
- },
617
+ commands: normalizedCommands,
348
618
  done: done.length > 0 ? done : defaults.done,
349
619
  approvals: {
350
620
  mode: typeof approvals.mode === "string" ? approvals.mode : defaults.approvals.mode,
@@ -354,6 +624,190 @@ function normalizeRules(input, defaults) {
354
624
  };
355
625
  }
356
626
 
627
+ function isPlainObject(value) {
628
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
629
+ }
630
+
631
+ function getSuspiciousRuleLines(text) {
632
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
633
+ const suspicious = [];
634
+ let blockBaseIndent = null;
635
+
636
+ for (let index = 0; index < lines.length; index += 1) {
637
+ const line = lines[index];
638
+ const indent = line.match(/^ */)[0].length;
639
+ const trimmed = line.trim();
640
+
641
+ if (blockBaseIndent !== null) {
642
+ if (!trimmed) {
643
+ continue;
644
+ }
645
+ if (indent > blockBaseIndent) {
646
+ continue;
647
+ }
648
+ blockBaseIndent = null;
649
+ }
650
+
651
+ if (!trimmed || trimmed.startsWith("#")) {
652
+ continue;
653
+ }
654
+
655
+ const pair = splitKeyValueLine(trimmed, { allowTightValue: true });
656
+ if (pair) {
657
+ if (isBlockScalarIndicator(pair[1])) {
658
+ blockBaseIndent = indent;
659
+ }
660
+ continue;
661
+ }
662
+
663
+ if (/^-\s+/.test(trimmed)) {
664
+ continue;
665
+ }
666
+
667
+ suspicious.push(index + 1);
668
+ }
669
+ return suspicious;
670
+ }
671
+
672
+ function validateRulesSource(source, rawText) {
673
+ const warnings = [];
674
+ const errors = [];
675
+
676
+ if (!isPlainObject(source)) {
677
+ errors.push("Top-level YAML must be an object.");
678
+ return { warnings, errors };
679
+ }
680
+
681
+ const keys = Object.keys(source);
682
+ const nonCommentLines = rawText
683
+ .replace(/\r\n/g, "\n")
684
+ .split("\n")
685
+ .filter((line) => line.trim() && !line.trim().startsWith("#"));
686
+ if (keys.length === 0 && nonCommentLines.length > 0) {
687
+ errors.push("No parseable keys found. Check YAML syntax and indentation.");
688
+ }
689
+
690
+ for (const key of REQUIRED_RULE_KEYS) {
691
+ if (!Object.prototype.hasOwnProperty.call(source, key)) {
692
+ warnings.push(`Missing "${key}" key; defaults will be applied.`);
693
+ }
694
+ }
695
+
696
+ const suspiciousLines = getSuspiciousRuleLines(rawText);
697
+ if (suspiciousLines.length > 0) {
698
+ warnings.push(
699
+ `Suspicious YAML line(s): ${suspiciousLines.slice(0, 8).join(", ")}${
700
+ suspiciousLines.length > 8 ? ", ..." : ""
701
+ }`,
702
+ );
703
+ }
704
+
705
+ if (Object.prototype.hasOwnProperty.call(source, "version") && typeof source.version !== "number") {
706
+ errors.push(`"version" must be a number.`);
707
+ }
708
+ if (
709
+ Object.prototype.hasOwnProperty.call(source, "mission") &&
710
+ (typeof source.mission !== "string" || !source.mission.trim())
711
+ ) {
712
+ errors.push(`"mission" must be a non-empty string.`);
713
+ }
714
+ if (
715
+ Object.prototype.hasOwnProperty.call(source, "workflow") &&
716
+ (!Array.isArray(source.workflow) || source.workflow.some((item) => typeof item !== "string"))
717
+ ) {
718
+ errors.push(`"workflow" must be an array of strings.`);
719
+ }
720
+ if (
721
+ Object.prototype.hasOwnProperty.call(source, "done") &&
722
+ (!Array.isArray(source.done) || source.done.some((item) => typeof item !== "string"))
723
+ ) {
724
+ errors.push(`"done" must be an array of strings.`);
725
+ }
726
+
727
+ if (Object.prototype.hasOwnProperty.call(source, "commands")) {
728
+ if (!isPlainObject(source.commands)) {
729
+ errors.push(`"commands" must be an object.`);
730
+ } else {
731
+ for (const [name, value] of Object.entries(source.commands)) {
732
+ if (typeof value !== "string" || !value.trim()) {
733
+ errors.push(`"commands.${name}" must be a non-empty string.`);
734
+ }
735
+ }
736
+ }
737
+ }
738
+
739
+ if (Object.prototype.hasOwnProperty.call(source, "approvals")) {
740
+ if (!isPlainObject(source.approvals)) {
741
+ errors.push(`"approvals" must be an object.`);
742
+ } else {
743
+ if (
744
+ Object.prototype.hasOwnProperty.call(source.approvals, "mode") &&
745
+ (typeof source.approvals.mode !== "string" || !source.approvals.mode.trim())
746
+ ) {
747
+ errors.push(`"approvals.mode" must be a non-empty string.`);
748
+ }
749
+ if (
750
+ Object.prototype.hasOwnProperty.call(source.approvals, "notes") &&
751
+ (!Array.isArray(source.approvals.notes) ||
752
+ source.approvals.notes.some((item) => typeof item !== "string"))
753
+ ) {
754
+ errors.push(`"approvals.notes" must be an array of strings.`);
755
+ }
756
+ }
757
+ }
758
+
759
+ if (Object.prototype.hasOwnProperty.call(source, "targets")) {
760
+ if (!isPlainObject(source.targets)) {
761
+ errors.push(`"targets" must be an object.`);
762
+ } else {
763
+ for (const [targetId, config] of Object.entries(source.targets)) {
764
+ if (typeof config === "string") {
765
+ if (!config.trim()) {
766
+ errors.push(`"targets.${targetId}" must not be empty.`);
767
+ }
768
+ continue;
769
+ }
770
+ if (!isPlainObject(config)) {
771
+ errors.push(`"targets.${targetId}" must be a string or object.`);
772
+ continue;
773
+ }
774
+ if (
775
+ Object.prototype.hasOwnProperty.call(config, "enabled") &&
776
+ typeof config.enabled !== "boolean"
777
+ ) {
778
+ errors.push(`"targets.${targetId}.enabled" must be boolean.`);
779
+ }
780
+ if (Object.prototype.hasOwnProperty.call(config, "path")) {
781
+ if (typeof config.path !== "string" || !config.path.trim()) {
782
+ errors.push(`"targets.${targetId}.path" must be a non-empty string.`);
783
+ }
784
+ } else {
785
+ warnings.push(`"targets.${targetId}" has no "path"; default path will be used.`);
786
+ }
787
+ }
788
+ }
789
+ }
790
+
791
+ return { warnings, errors };
792
+ }
793
+
794
+ function formatValidationMessages(validation) {
795
+ const lines = [];
796
+ if (validation.errors.length > 0) {
797
+ lines.push("rules.yaml validation errors:");
798
+ for (const error of validation.errors) {
799
+ lines.push(`- ${error}`);
800
+ }
801
+ }
802
+ if (validation.warnings.length > 0) {
803
+ lines.push("rules.yaml validation warnings:");
804
+ for (const warning of validation.warnings) {
805
+ lines.push(`- ${warning}`);
806
+ }
807
+ }
808
+ return lines.join("\n");
809
+ }
810
+
357
811
  function stringifyRules(rules) {
358
812
  const knownTargetIds = ADAPTERS.map((adapter) => adapter.id);
359
813
  const allTargetIds = [
@@ -363,13 +817,19 @@ function stringifyRules(rules) {
363
817
  .sort(),
364
818
  ];
365
819
 
820
+ const commandNames = Object.keys(rules.commands || {});
821
+ const orderedCommandNames = [
822
+ ...["lint", "test", "build"].filter((name) => commandNames.includes(name)),
823
+ ...commandNames.filter((name) => !["lint", "test", "build"].includes(name)).sort(),
824
+ ];
825
+
366
826
  const lines = [
367
827
  `version: ${quoteYaml(Number.isFinite(rules.version) ? rules.version : 2)}`,
368
828
  `mission: ${quoteYaml(rules.mission)}`,
369
829
  "workflow:",
370
830
  ...rules.workflow.map((step) => ` - ${quoteYaml(step)}`),
371
831
  "commands:",
372
- ...Object.keys(rules.commands).map((name) => ` ${name}: ${quoteYaml(rules.commands[name])}`),
832
+ ...orderedCommandNames.map((name) => ` ${name}: ${quoteYaml(rules.commands[name])}`),
373
833
  "done:",
374
834
  ...rules.done.map((item) => ` - ${quoteYaml(item)}`),
375
835
  "approvals:",
@@ -390,33 +850,44 @@ function stringifyRules(rules) {
390
850
  return lines.join("\n");
391
851
  }
392
852
 
393
- function hasVerifyCommand(text) {
394
- return /\b(npm run|pnpm|yarn|bun)\s+(lint|test|build)\b/i.test(text);
395
- }
853
+ function resolveInRoot(rootDir, filePath) {
854
+ if (typeof filePath !== "string" || !filePath.trim()) {
855
+ throw new Error("Target path must be a non-empty string.");
856
+ }
396
857
 
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
- }
858
+ const trimmedPath = filePath.trim();
859
+ if (isAbsolute(trimmedPath)) {
860
+ throw new Error(`Target path must be project-relative: ${trimmedPath}`);
861
+ }
402
862
 
403
- function hasAskApprovalLanguage(text) {
404
- return /ask for approval|request approval|require approval|needs approval/i.test(text);
405
- }
863
+ const resolvedPath = resolve(rootDir, trimmedPath);
864
+ const rel = relative(rootDir, resolvedPath);
865
+ if (rel.startsWith("..") || isAbsolute(rel)) {
866
+ throw new Error(`Target path escapes project root: ${trimmedPath}`);
867
+ }
406
868
 
407
- function hasRequireTestsLanguage(text) {
408
- return /must run tests|always run tests|run tests before done/i.test(text);
869
+ return resolvedPath;
409
870
  }
410
871
 
411
- function hasSkipTestsLanguage(text) {
412
- return /skip tests|tests optional|do not run tests/i.test(text);
413
- }
872
+ function assertNoSymlinkTraversal(rootDir, targetPath) {
873
+ const rel = relative(rootDir, targetPath);
874
+ if (!rel || rel === ".") {
875
+ return;
876
+ }
414
877
 
415
- function resolveInRoot(rootDir, filePath) {
416
- if (isAbsolute(filePath)) {
417
- return filePath;
878
+ const segments = rel.split(/[/\\]+/).filter(Boolean);
879
+ let cursor = rootDir;
880
+ for (const segment of segments) {
881
+ cursor = resolve(cursor, segment);
882
+ if (!existsSync(cursor)) {
883
+ continue;
884
+ }
885
+ const stats = lstatSync(cursor);
886
+ if (stats.isSymbolicLink()) {
887
+ const display = relative(rootDir, cursor) || ".";
888
+ throw new Error(`Refusing symlink path for managed output: ${display}`);
889
+ }
418
890
  }
419
- return resolve(rootDir, filePath);
420
891
  }
421
892
 
422
893
  function findProjectRoot(startDir) {
@@ -440,19 +911,112 @@ function ensureParentDirectory(filePath) {
440
911
  mkdirSync(dirname(filePath), { recursive: true });
441
912
  }
442
913
 
443
- function upsertManagedSection(existing, content, beginMarker, endMarker) {
444
- const start = existing.indexOf(beginMarker);
445
- const end = start >= 0 ? existing.indexOf(endMarker, start) : -1;
914
+ function countOccurrences(text, needle) {
915
+ if (!needle) {
916
+ return 0;
917
+ }
918
+ let count = 0;
919
+ let cursor = 0;
920
+ while (true) {
921
+ const index = text.indexOf(needle, cursor);
922
+ if (index < 0) {
923
+ return count;
924
+ }
925
+ count += 1;
926
+ cursor = index + needle.length;
927
+ }
928
+ }
929
+
930
+ function escapeRegExp(value) {
931
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
932
+ }
446
933
 
447
- if (start >= 0 && end > start) {
448
- const before = existing.slice(0, start + beginMarker.length);
449
- const after = existing.slice(end);
934
+ function inspectMarkerBlock(text, beginMarker, endMarker) {
935
+ const beginCount = countOccurrences(text, beginMarker);
936
+ const endCount = countOccurrences(text, endMarker);
937
+ const firstBegin = text.indexOf(beginMarker);
938
+ const firstEnd = text.indexOf(endMarker);
939
+
940
+ if (beginCount === 0 && endCount === 0) {
941
+ return { status: "missing", beginCount, endCount, firstBegin, firstEnd };
942
+ }
943
+ if (beginCount === 1 && endCount === 1 && firstBegin >= 0 && firstEnd > firstBegin) {
944
+ return { status: "valid", beginCount, endCount, firstBegin, firstEnd };
945
+ }
946
+ if (beginCount === 1 && endCount === 0) {
947
+ return { status: "missing-end", beginCount, endCount, firstBegin, firstEnd };
948
+ }
949
+ if (beginCount === 0 && endCount === 1) {
950
+ return { status: "missing-begin", beginCount, endCount, firstBegin, firstEnd };
951
+ }
952
+ if (firstBegin >= 0 && firstEnd >= 0 && firstEnd < firstBegin) {
953
+ return { status: "misordered", beginCount, endCount, firstBegin, firstEnd };
954
+ }
955
+ return { status: "multiple", beginCount, endCount, firstBegin, firstEnd };
956
+ }
957
+
958
+ function markerStatusIssueLabel(status) {
959
+ if (status === "missing") {
960
+ return "marker block is missing.";
961
+ }
962
+ if (status === "missing-end") {
963
+ return "marker block is malformed (missing end marker).";
964
+ }
965
+ if (status === "missing-begin") {
966
+ return "marker block is malformed (missing begin marker).";
967
+ }
968
+ if (status === "misordered") {
969
+ return "marker block is malformed (end marker appears before begin marker).";
970
+ }
971
+ if (status === "multiple") {
972
+ return "marker block is malformed (multiple marker blocks detected).";
973
+ }
974
+ return "marker block state is unknown.";
975
+ }
976
+
977
+ function removeMarkerLines(text, beginMarker, endMarker) {
978
+ return text
979
+ .split("\n")
980
+ .filter((line) => {
981
+ const trimmed = line.trim();
982
+ return trimmed !== beginMarker && trimmed !== endMarker;
983
+ })
984
+ .join("\n");
985
+ }
986
+
987
+ function cleanMalformedMarkerContent(existing, beginMarker, endMarker, inspection) {
988
+ if (inspection.status === "missing-end" && inspection.firstBegin >= 0) {
989
+ return existing.slice(0, inspection.firstBegin).trimEnd();
990
+ }
991
+
992
+ let cleaned = existing;
993
+ const beginPattern = escapeRegExp(beginMarker);
994
+ const endPattern = escapeRegExp(endMarker);
995
+ const sectionPattern = new RegExp(`${beginPattern}[\\s\\S]*?${endPattern}\\n?`, "g");
996
+ cleaned = cleaned.replace(sectionPattern, "");
997
+ cleaned = removeMarkerLines(cleaned, beginMarker, endMarker);
998
+ return cleaned.trimEnd();
999
+ }
1000
+
1001
+ function upsertManagedSection(existing, content, beginMarker, endMarker) {
1002
+ const inspection = inspectMarkerBlock(existing, beginMarker, endMarker);
1003
+ if (inspection.status === "valid") {
1004
+ const before = existing.slice(0, inspection.firstBegin + beginMarker.length);
1005
+ const after = existing.slice(inspection.firstEnd);
450
1006
  return `${before}\n${content.trim()}\n${after}`.replace(/\n{3,}/g, "\n\n");
451
1007
  }
452
1008
 
453
- const base = existing.trimEnd();
1009
+ const managedBlock = `${beginMarker}\n${content.trim()}\n${endMarker}\n`;
1010
+ if (inspection.status === "missing") {
1011
+ const base = existing.trimEnd();
1012
+ const prefix = base ? `${base}\n\n` : "";
1013
+ return `${prefix}${managedBlock}`;
1014
+ }
1015
+
1016
+ const cleaned = cleanMalformedMarkerContent(existing, beginMarker, endMarker, inspection);
1017
+ const base = cleaned.trimEnd();
454
1018
  const prefix = base ? `${base}\n\n` : "";
455
- return `${prefix}${beginMarker}\n${content.trim()}\n${endMarker}\n`;
1019
+ return `${prefix}${managedBlock}`;
456
1020
  }
457
1021
 
458
1022
  function loadPackageScripts(rootDir) {
@@ -464,25 +1028,27 @@ function loadPackageScripts(rootDir) {
464
1028
  }
465
1029
 
466
1030
  function loadRules(rootDir, options) {
1031
+ const opts = options || {};
467
1032
  const rulesFile = resolve(rootDir, RULES_RELATIVE_PATH);
468
1033
  const defaults = createDefaultRules(loadPackageScripts(rootDir));
469
1034
 
470
1035
  if (!existsSync(rulesFile)) {
471
- if (options && options.allowMissing) {
472
- return {
473
- rules: defaults,
474
- rulesFile,
475
- rulesExists: false,
476
- };
477
- }
478
1036
  throw new Error(`Missing ${rulesFile}. Run "rules-doctor init" to create it first.`);
479
1037
  }
480
1038
 
481
- const parsed = parseRulesText(readFileSync(rulesFile, "utf8"));
1039
+ const rawText = readFileSync(rulesFile, "utf8");
1040
+ const parsed = parseRulesText(rawText);
1041
+ const validation = validateRulesSource(parsed, rawText);
1042
+ if (validation.errors.length > 0) {
1043
+ throw new Error(formatValidationMessages(validation));
1044
+ }
1045
+ if (validation.warnings.length > 0 && opts.logger && typeof opts.logger.log === "function") {
1046
+ opts.logger.log(formatValidationMessages(validation));
1047
+ }
1048
+
482
1049
  return {
483
1050
  rules: normalizeRules(parsed, defaults),
484
1051
  rulesFile,
485
- rulesExists: true,
486
1052
  };
487
1053
  }
488
1054
 
@@ -517,7 +1083,6 @@ function getTargetsFromSpec(spec) {
517
1083
  function parseInitArgs(args) {
518
1084
  const options = {
519
1085
  importExisting: false,
520
- preset: "all",
521
1086
  };
522
1087
 
523
1088
  for (let index = 0; index < (args || []).length; index += 1) {
@@ -527,63 +1092,12 @@ function parseInitArgs(args) {
527
1092
  continue;
528
1093
  }
529
1094
 
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
1095
  throw new Error(`Unknown option for init: ${arg}`);
544
1096
  }
545
1097
 
546
1098
  return options;
547
1099
  }
548
1100
 
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;
585
- }
586
-
587
1101
  function parseTargetedArgs(commandName, args, extra) {
588
1102
  const options = {
589
1103
  targetSpec: "all",
@@ -631,38 +1145,6 @@ function parseTargetedArgs(commandName, args, extra) {
631
1145
  };
632
1146
  }
633
1147
 
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;
648
- }
649
-
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;
664
- }
665
-
666
1148
  function getTargetConfig(rules, adapter) {
667
1149
  const source = rules.targets && typeof rules.targets === "object" ? rules.targets[adapter.id] : null;
668
1150
  return normalizeTargetConfig(source, adapter.defaultPath);
@@ -780,12 +1262,12 @@ function getSectionText(sections, aliases) {
780
1262
 
781
1263
  function collectImportSources(rootDir) {
782
1264
  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" },
1265
+ { path: "CLAUDE.md" },
1266
+ { path: ".claude/CLAUDE.md" },
1267
+ { path: "AGENTS.md" },
1268
+ { path: ".github/copilot-instructions.md" },
1269
+ { path: "GEMINI.md" },
1270
+ { path: ".cursor/rules/rules-doctor.mdc" },
789
1271
  ];
790
1272
 
791
1273
  const uniquePaths = new Set();
@@ -802,9 +1284,7 @@ function collectImportSources(rootDir) {
802
1284
  continue;
803
1285
  }
804
1286
  sources.push({
805
- id: item.id,
806
1287
  path: item.path,
807
- absolutePath,
808
1288
  text: readFileSync(absolutePath, "utf8"),
809
1289
  });
810
1290
  }
@@ -821,7 +1301,6 @@ function importRulesFromDocs(rootDir, defaults) {
821
1301
  return {
822
1302
  rules: imported,
823
1303
  report: "No existing docs were found. Created default rules.",
824
- sources,
825
1304
  };
826
1305
  }
827
1306
 
@@ -880,7 +1359,6 @@ function importRulesFromDocs(rootDir, defaults) {
880
1359
  return {
881
1360
  rules: imported,
882
1361
  report: notes.join("\n"),
883
- sources,
884
1362
  };
885
1363
  }
886
1364
 
@@ -902,67 +1380,19 @@ function initCommand(rootDir, logger, args) {
902
1380
  importReport = imported.report;
903
1381
  }
904
1382
 
905
- rules = applyTargetPreset(rules, options.preset);
906
-
907
1383
  ensureParentDirectory(rulesFile);
908
1384
  writeFileSync(rulesFile, stringifyRules(rules), "utf8");
909
1385
  logger.log(`Created ${rulesFile}`);
910
- logger.log(`Applied target preset: ${options.preset}`);
911
1386
 
912
1387
  if (options.importExisting) {
913
1388
  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");
1389
+ writeFileSync(reportPath, `${importReport}\n`, "utf8");
919
1390
  logger.log(`Import report: ${reportPath}`);
920
1391
  }
921
1392
 
922
1393
  return 0;
923
1394
  }
924
1395
 
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
1396
  function buildTargetPlans(rootDir, rules, targetIds) {
967
1397
  const plans = [];
968
1398
 
@@ -970,6 +1400,7 @@ function buildTargetPlans(rootDir, rules, targetIds) {
970
1400
  const adapter = ADAPTERS_BY_ID[targetId];
971
1401
  const target = getTargetConfig(rules, adapter);
972
1402
  const targetPath = resolveInRoot(rootDir, target.path);
1403
+ assertNoSymlinkTraversal(rootDir, targetPath);
973
1404
  const fileExists = existsSync(targetPath);
974
1405
  const currentText = fileExists ? readFileSync(targetPath, "utf8") : "";
975
1406
 
@@ -1051,9 +1482,73 @@ function renderSimpleDiff(currentText, desiredText) {
1051
1482
  function formatPlanSummary(plans) {
1052
1483
  const changed = plans.filter((plan) => plan.changed).length;
1053
1484
  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 };
1485
+ return { changed, changedFiles };
1486
+ }
1487
+
1488
+ function analyzeSharedPathPlans(plans) {
1489
+ const groups = new Map();
1490
+ for (const plan of plans) {
1491
+ if (!plan.enabled) {
1492
+ continue;
1493
+ }
1494
+ if (!groups.has(plan.targetPath)) {
1495
+ groups.set(plan.targetPath, []);
1496
+ }
1497
+ groups.get(plan.targetPath).push(plan);
1498
+ }
1499
+
1500
+ const shared = [];
1501
+ const conflicts = [];
1502
+ for (const plansAtPath of groups.values()) {
1503
+ if (plansAtPath.length < 2) {
1504
+ continue;
1505
+ }
1506
+
1507
+ const ids = plansAtPath.map((plan) => plan.targetId);
1508
+ const firstDesired = plansAtPath[0].desiredText;
1509
+ const sameDesired = plansAtPath.every((plan) => plan.desiredText === firstDesired);
1510
+ if (sameDesired) {
1511
+ shared.push({
1512
+ targetPathDisplay: plansAtPath[0].targetPathDisplay,
1513
+ ids,
1514
+ });
1515
+ continue;
1516
+ }
1517
+
1518
+ conflicts.push({
1519
+ targetPathDisplay: plansAtPath[0].targetPathDisplay,
1520
+ ids,
1521
+ });
1522
+ }
1523
+
1524
+ return { shared, conflicts };
1525
+ }
1526
+
1527
+ function validateSyncPlans(plans) {
1528
+ const mapping = analyzeSharedPathPlans(plans);
1529
+ const issues = mapping.conflicts.map(
1530
+ (group) =>
1531
+ `Conflicting outputs map to the same file: ${group.ids.join(", ")} -> ${group.targetPathDisplay}`,
1532
+ );
1533
+ const warnings = [];
1534
+
1535
+ for (const plan of plans) {
1536
+ if (!plan.enabled || !plan.exists || plan.adapter.management !== "marker") {
1537
+ continue;
1538
+ }
1539
+ const marker = inspectMarkerBlock(
1540
+ plan.currentText,
1541
+ plan.adapter.markerBegin,
1542
+ plan.adapter.markerEnd,
1543
+ );
1544
+ if (marker.status !== "valid" && marker.status !== "missing") {
1545
+ warnings.push(
1546
+ `${plan.targetId}: ${markerStatusIssueLabel(marker.status)} rules-doctor will repair it on sync.`,
1547
+ );
1548
+ }
1549
+ }
1550
+
1551
+ return { issues, warnings };
1057
1552
  }
1058
1553
 
1059
1554
  function getUniqueWritePlans(plans) {
@@ -1092,7 +1587,7 @@ function getUniqueWritePlans(plans) {
1092
1587
 
1093
1588
  function syncCommand(rootDir, logger, args) {
1094
1589
  const options = parseTargetedArgs("sync", args, { write: true, backup: true });
1095
- const { rules } = loadRules(rootDir);
1590
+ const { rules } = loadRules(rootDir, { logger });
1096
1591
  const plans = buildTargetPlans(rootDir, rules, options.targetIds);
1097
1592
  const summary = formatPlanSummary(plans);
1098
1593
 
@@ -1123,6 +1618,21 @@ function syncCommand(rootDir, logger, args) {
1123
1618
  }
1124
1619
  }
1125
1620
 
1621
+ if (options.write) {
1622
+ const preflight = validateSyncPlans(plans);
1623
+ for (const warning of preflight.warnings) {
1624
+ logger.log(` warning: ${warning}`);
1625
+ }
1626
+ if (preflight.issues.length > 0) {
1627
+ logger.log("Sync preflight failed:");
1628
+ for (const issue of preflight.issues) {
1629
+ logger.log(` - ${issue}`);
1630
+ }
1631
+ return 1;
1632
+ }
1633
+ logger.log("Sync preflight passed.");
1634
+ }
1635
+
1126
1636
  if (!options.write) {
1127
1637
  if (summary.changed === 0) {
1128
1638
  logger.log("Dry-run complete: no changes.");
@@ -1162,7 +1672,7 @@ function syncCommand(rootDir, logger, args) {
1162
1672
 
1163
1673
  function checkCommand(rootDir, logger, args) {
1164
1674
  const options = parseTargetedArgs("check", args, { write: false, backup: false });
1165
- const { rules } = loadRules(rootDir);
1675
+ const { rules } = loadRules(rootDir, { logger });
1166
1676
  const plans = buildTargetPlans(rootDir, rules, options.targetIds);
1167
1677
  const summary = formatPlanSummary(plans);
1168
1678
 
@@ -1199,160 +1709,6 @@ function checkCommand(rootDir, logger, args) {
1199
1709
  return 1;
1200
1710
  }
1201
1711
 
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}`);
1337
- }
1338
-
1339
- if (options.strict) {
1340
- return 1;
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
1712
  function runCli(argv, options) {
1357
1713
  const args = Array.isArray(argv) ? argv : [];
1358
1714
  const logger = createLogger(options || {});
@@ -1371,10 +1727,6 @@ function runCli(argv, options) {
1371
1727
  return initCommand(rootDir, logger, rest);
1372
1728
  }
1373
1729
 
1374
- if (command === "preset") {
1375
- return presetApplyCommand(rootDir, logger, rest);
1376
- }
1377
-
1378
1730
  if (command === "sync") {
1379
1731
  return syncCommand(rootDir, logger, rest);
1380
1732
  }
@@ -1383,22 +1735,6 @@ function runCli(argv, options) {
1383
1735
  return checkCommand(rootDir, logger, rest);
1384
1736
  }
1385
1737
 
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
1738
  throw new Error(`Unknown command: ${command}\n\n${usage()}`);
1403
1739
  } catch (error) {
1404
1740
  const message = error instanceof Error ? error.message : String(error);