@jazpiper/rules-doctor 0.2.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.
package/dist/index.js CHANGED
@@ -1,9 +1,19 @@
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
+ const IMPORT_REPORT_RELATIVE_PATH = ".agentrules/import-report.md";
8
+ const REQUIRED_RULE_KEYS = [
9
+ "version",
10
+ "mission",
11
+ "workflow",
12
+ "commands",
13
+ "done",
14
+ "approvals",
15
+ "targets",
16
+ ];
7
17
 
8
18
  function usage() {
9
19
  const targets = ADAPTERS.map((adapter) => adapter.id).join("|");
@@ -11,10 +21,12 @@ function usage() {
11
21
  "rules-doctor",
12
22
  "",
13
23
  "Usage:",
14
- " rules-doctor init",
15
- ` rules-doctor sync [--target all|${targets}|<comma-separated-targets>]`,
16
- " rules-doctor analyze",
17
- " rules-doctor targets list",
24
+ " rules-doctor init [--import]",
25
+ ` rules-doctor sync [--target all|${targets}|<comma-separated-targets>] [--diff] [--write] [--backup]`,
26
+ ` rules-doctor check [--target all|${targets}|<comma-separated-targets>] [--diff]`,
27
+ "",
28
+ "Notes:",
29
+ " - sync defaults to dry-run. Add --write to apply changes.",
18
30
  ].join("\n");
19
31
  }
20
32
 
@@ -45,12 +57,12 @@ function readJsonFile(filePath) {
45
57
 
46
58
  function stripQuotes(value) {
47
59
  const trimmed = value.trim();
48
- if (
49
- (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
50
- (trimmed.startsWith("'") && trimmed.endsWith("'"))
51
- ) {
60
+ if (trimmed.startsWith("'") && trimmed.endsWith("'")) {
61
+ return trimmed.slice(1, -1).replace(/''/g, "'");
62
+ }
63
+ if (trimmed.startsWith('"') && trimmed.endsWith('"')) {
52
64
  try {
53
- return JSON.parse(trimmed.replace(/'/g, '"'));
65
+ return JSON.parse(trimmed);
54
66
  } catch {
55
67
  return trimmed.slice(1, -1);
56
68
  }
@@ -58,8 +70,285 @@ function stripQuotes(value) {
58
70
  return trimmed;
59
71
  }
60
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
+
61
340
  function parseScalar(value) {
62
- 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);
63
352
  if (/^(true|false)$/i.test(cleaned)) {
64
353
  return cleaned.toLowerCase() === "true";
65
354
  }
@@ -88,8 +377,10 @@ function parseRulesText(text) {
88
377
  let section = null;
89
378
  let nested = null;
90
379
  let currentTarget = null;
380
+ let targetEntryIndent = null;
91
381
 
92
- for (const rawLine of lines) {
382
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
383
+ const rawLine = lines[lineIndex];
93
384
  if (!rawLine.trim() || rawLine.trim().startsWith("#")) {
94
385
  continue;
95
386
  }
@@ -100,14 +391,15 @@ function parseRulesText(text) {
100
391
  if (indent === 0) {
101
392
  nested = null;
102
393
  currentTarget = null;
394
+ targetEntryIndent = null;
103
395
 
104
- const top = line.match(/^([a-zA-Z0-9_-]+):(.*)$/);
396
+ const top = splitKeyValueLine(line);
105
397
  if (!top) {
106
398
  continue;
107
399
  }
108
400
 
109
- const key = top[1];
110
- const value = top[2].trim();
401
+ const key = top[0];
402
+ const value = stripInlineComment(top[1]).trim();
111
403
 
112
404
  if (!value) {
113
405
  section = key;
@@ -118,7 +410,13 @@ function parseRulesText(text) {
118
410
  }
119
411
  } else {
120
412
  section = null;
121
- 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
+ }
122
420
  }
123
421
  continue;
124
422
  }
@@ -129,20 +427,22 @@ function parseRulesText(text) {
129
427
  }
130
428
 
131
429
  if (section === "commands") {
132
- const pair = line.match(/^([a-zA-Z0-9_-]+):(.*)$/);
430
+ const pair = splitKeyValueLine(line);
133
431
  if (pair) {
134
- data.commands[pair[1]] = parseScalar(pair[2].trim());
432
+ data.commands[pair[0]] = parseScalar(pair[1]);
135
433
  }
136
434
  continue;
137
435
  }
138
436
 
139
437
  if (section === "approvals") {
140
- if (line.startsWith("mode:")) {
141
- 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]);
142
442
  continue;
143
443
  }
144
444
 
145
- if (line === "notes:") {
445
+ if (pair && pair[0] === "notes" && !stripInlineComment(pair[1]).trim()) {
146
446
  nested = "notes";
147
447
  if (!Array.isArray(data.approvals.notes)) {
148
448
  data.approvals.notes = [];
@@ -157,14 +457,20 @@ function parseRulesText(text) {
157
457
  }
158
458
 
159
459
  if (section === "targets") {
160
- if (indent === 2) {
161
- const target = line.match(/^([a-zA-Z0-9_-]+):(.*)$/);
162
- if (!target) {
163
- continue;
164
- }
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
+ }
165
471
 
166
- currentTarget = target[1];
167
- const maybeValue = target[2].trim();
472
+ if (indent === targetEntryIndent) {
473
+ currentTarget = key;
168
474
  if (!maybeValue) {
169
475
  data.targets[currentTarget] = {};
170
476
  } else {
@@ -173,14 +479,11 @@ function parseRulesText(text) {
173
479
  continue;
174
480
  }
175
481
 
176
- if (indent >= 4 && currentTarget) {
177
- const pair = line.match(/^([a-zA-Z0-9_-]+):(.*)$/);
178
- if (pair) {
179
- if (!data.targets[currentTarget] || typeof data.targets[currentTarget] !== "object") {
180
- data.targets[currentTarget] = {};
181
- }
182
- 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] = {};
183
485
  }
486
+ data.targets[currentTarget][key] = parseScalar(maybeValue);
184
487
  }
185
488
  }
186
489
  }
@@ -272,10 +575,31 @@ function normalizeRules(input, defaults) {
272
575
  const notes = Array.isArray(approvals.notes)
273
576
  ? approvals.notes.filter((item) => typeof item === "string")
274
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
+ }
275
592
 
276
593
  const targets = {};
277
594
  for (const adapter of ADAPTERS) {
278
- targets[adapter.id] = normalizeTargetConfig(sourceTargets[adapter.id], defaults.targets[adapter.id].path);
595
+ const fallback = defaults.targets[adapter.id]
596
+ ? defaults.targets[adapter.id].path
597
+ : adapter.defaultPath;
598
+ const config = normalizeTargetConfig(sourceTargets[adapter.id], fallback);
599
+ targets[adapter.id] = {
600
+ enabled: typeof config.enabled === "boolean" ? config.enabled : true,
601
+ path: config.path,
602
+ };
279
603
  }
280
604
 
281
605
  for (const customId of Object.keys(sourceTargets)) {
@@ -290,11 +614,7 @@ function normalizeRules(input, defaults) {
290
614
  mission:
291
615
  typeof source.mission === "string" && source.mission.trim() ? source.mission : defaults.mission,
292
616
  workflow: workflow.length > 0 ? workflow : defaults.workflow,
293
- commands: {
294
- lint: typeof commands.lint === "string" ? commands.lint : defaults.commands.lint,
295
- test: typeof commands.test === "string" ? commands.test : defaults.commands.test,
296
- build: typeof commands.build === "string" ? commands.build : defaults.commands.build,
297
- },
617
+ commands: normalizedCommands,
298
618
  done: done.length > 0 ? done : defaults.done,
299
619
  approvals: {
300
620
  mode: typeof approvals.mode === "string" ? approvals.mode : defaults.approvals.mode,
@@ -304,6 +624,190 @@ function normalizeRules(input, defaults) {
304
624
  };
305
625
  }
306
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
+
307
811
  function stringifyRules(rules) {
308
812
  const knownTargetIds = ADAPTERS.map((adapter) => adapter.id);
309
813
  const allTargetIds = [
@@ -313,13 +817,19 @@ function stringifyRules(rules) {
313
817
  .sort(),
314
818
  ];
315
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
+
316
826
  const lines = [
317
827
  `version: ${quoteYaml(Number.isFinite(rules.version) ? rules.version : 2)}`,
318
828
  `mission: ${quoteYaml(rules.mission)}`,
319
829
  "workflow:",
320
830
  ...rules.workflow.map((step) => ` - ${quoteYaml(step)}`),
321
831
  "commands:",
322
- ...Object.keys(rules.commands).map((name) => ` ${name}: ${quoteYaml(rules.commands[name])}`),
832
+ ...orderedCommandNames.map((name) => ` ${name}: ${quoteYaml(rules.commands[name])}`),
323
833
  "done:",
324
834
  ...rules.done.map((item) => ` - ${quoteYaml(item)}`),
325
835
  "approvals:",
@@ -340,33 +850,44 @@ function stringifyRules(rules) {
340
850
  return lines.join("\n");
341
851
  }
342
852
 
343
- function hasVerifyCommand(text) {
344
- return /\b(npm run|pnpm|yarn|bun)\s+(lint|test|build)\b/i.test(text);
345
- }
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
+ }
346
857
 
347
- function hasNoApprovalLanguage(text) {
348
- return /never ask (for )?approval|no approvals|without approval|do not ask for approval/i.test(
349
- text,
350
- );
351
- }
858
+ const trimmedPath = filePath.trim();
859
+ if (isAbsolute(trimmedPath)) {
860
+ throw new Error(`Target path must be project-relative: ${trimmedPath}`);
861
+ }
352
862
 
353
- function hasAskApprovalLanguage(text) {
354
- return /ask for approval|request approval|require approval|needs approval/i.test(text);
355
- }
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
+ }
356
868
 
357
- function hasRequireTestsLanguage(text) {
358
- return /must run tests|always run tests|run tests before done/i.test(text);
869
+ return resolvedPath;
359
870
  }
360
871
 
361
- function hasSkipTestsLanguage(text) {
362
- return /skip tests|tests optional|do not run tests/i.test(text);
363
- }
872
+ function assertNoSymlinkTraversal(rootDir, targetPath) {
873
+ const rel = relative(rootDir, targetPath);
874
+ if (!rel || rel === ".") {
875
+ return;
876
+ }
364
877
 
365
- function resolveInRoot(rootDir, filePath) {
366
- if (isAbsolute(filePath)) {
367
- 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
+ }
368
890
  }
369
- return resolve(rootDir, filePath);
370
891
  }
371
892
 
372
893
  function findProjectRoot(startDir) {
@@ -390,19 +911,112 @@ function ensureParentDirectory(filePath) {
390
911
  mkdirSync(dirname(filePath), { recursive: true });
391
912
  }
392
913
 
393
- function upsertManagedSection(existing, content, beginMarker, endMarker) {
394
- const start = existing.indexOf(beginMarker);
395
- 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
+ }
933
+
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
+ }
396
1000
 
397
- if (start >= 0 && end > start) {
398
- const before = existing.slice(0, start + beginMarker.length);
399
- const after = existing.slice(end);
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);
400
1006
  return `${before}\n${content.trim()}\n${after}`.replace(/\n{3,}/g, "\n\n");
401
1007
  }
402
1008
 
403
- 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();
404
1018
  const prefix = base ? `${base}\n\n` : "";
405
- return `${prefix}${beginMarker}\n${content.trim()}\n${endMarker}\n`;
1019
+ return `${prefix}${managedBlock}`;
406
1020
  }
407
1021
 
408
1022
  function loadPackageScripts(rootDir) {
@@ -414,25 +1028,27 @@ function loadPackageScripts(rootDir) {
414
1028
  }
415
1029
 
416
1030
  function loadRules(rootDir, options) {
1031
+ const opts = options || {};
417
1032
  const rulesFile = resolve(rootDir, RULES_RELATIVE_PATH);
418
1033
  const defaults = createDefaultRules(loadPackageScripts(rootDir));
419
1034
 
420
1035
  if (!existsSync(rulesFile)) {
421
- if (options && options.allowMissing) {
422
- return {
423
- rules: defaults,
424
- rulesFile,
425
- rulesExists: false,
426
- };
427
- }
428
1036
  throw new Error(`Missing ${rulesFile}. Run "rules-doctor init" to create it first.`);
429
1037
  }
430
1038
 
431
- 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
+
432
1049
  return {
433
1050
  rules: normalizeRules(parsed, defaults),
434
1051
  rulesFile,
435
- rulesExists: true,
436
1052
  };
437
1053
  }
438
1054
 
@@ -464,204 +1080,633 @@ function getTargetsFromSpec(spec) {
464
1080
  return unique;
465
1081
  }
466
1082
 
467
- function parseSyncTargets(args) {
468
- if (!args || args.length === 0) {
469
- return ADAPTERS.map((adapter) => adapter.id);
1083
+ function parseInitArgs(args) {
1084
+ const options = {
1085
+ importExisting: false,
1086
+ };
1087
+
1088
+ for (let index = 0; index < (args || []).length; index += 1) {
1089
+ const arg = args[index];
1090
+ if (arg === "--import") {
1091
+ options.importExisting = true;
1092
+ continue;
1093
+ }
1094
+
1095
+ throw new Error(`Unknown option for init: ${arg}`);
470
1096
  }
471
1097
 
472
- let targetSpec = "all";
473
- for (let index = 0; index < args.length; index += 1) {
1098
+ return options;
1099
+ }
1100
+
1101
+ function parseTargetedArgs(commandName, args, extra) {
1102
+ const options = {
1103
+ targetSpec: "all",
1104
+ diff: false,
1105
+ write: false,
1106
+ backup: false,
1107
+ };
1108
+ const allowed = extra || {};
1109
+
1110
+ for (let index = 0; index < (args || []).length; index += 1) {
474
1111
  const arg = args[index];
475
1112
  if (arg === "--target") {
476
1113
  const value = args[index + 1];
477
1114
  if (!value) {
478
- throw new Error("Missing value for --target");
1115
+ throw new Error(`Missing value for --target (${commandName})`);
479
1116
  }
480
- targetSpec = value;
1117
+ options.targetSpec = value;
481
1118
  index += 1;
482
1119
  continue;
483
1120
  }
1121
+ if (arg === "--diff") {
1122
+ options.diff = true;
1123
+ continue;
1124
+ }
1125
+ if (arg === "--write" && allowed.write) {
1126
+ options.write = true;
1127
+ continue;
1128
+ }
1129
+ if (arg === "--backup" && allowed.backup) {
1130
+ options.backup = true;
1131
+ continue;
1132
+ }
1133
+ throw new Error(`Unknown option for ${commandName}: ${arg}`);
1134
+ }
484
1135
 
485
- throw new Error(`Unknown option for sync: ${arg}`);
1136
+ if (options.backup && !options.write) {
1137
+ throw new Error("--backup requires --write.");
486
1138
  }
487
1139
 
488
- return getTargetsFromSpec(targetSpec);
1140
+ return {
1141
+ targetIds: getTargetsFromSpec(options.targetSpec),
1142
+ diff: options.diff,
1143
+ write: options.write,
1144
+ backup: options.backup,
1145
+ };
489
1146
  }
490
1147
 
491
- function parseAnalyzeArgs(args) {
492
- if (!args || args.length === 0) {
493
- return {};
1148
+ function getTargetConfig(rules, adapter) {
1149
+ const source = rules.targets && typeof rules.targets === "object" ? rules.targets[adapter.id] : null;
1150
+ return normalizeTargetConfig(source, adapter.defaultPath);
1151
+ }
1152
+
1153
+ function normalizeHeading(value) {
1154
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
1155
+ }
1156
+
1157
+ function parseMarkdownSections(text) {
1158
+ const sections = {};
1159
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
1160
+ let currentHeading = null;
1161
+ let currentLines = [];
1162
+
1163
+ function flush() {
1164
+ if (!currentHeading) {
1165
+ return;
1166
+ }
1167
+ const normalized = normalizeHeading(currentHeading);
1168
+ if (!sections[normalized]) {
1169
+ sections[normalized] = currentLines.join("\n").trim();
1170
+ }
494
1171
  }
495
1172
 
496
- for (const arg of args) {
497
- if (arg === "--strict") {
498
- return { strict: true };
1173
+ for (const line of lines) {
1174
+ const heading = line.match(/^#{1,6}\s+(.+?)\s*$/);
1175
+ if (heading) {
1176
+ flush();
1177
+ currentHeading = heading[1];
1178
+ currentLines = [];
1179
+ continue;
1180
+ }
1181
+
1182
+ if (currentHeading) {
1183
+ currentLines.push(line);
499
1184
  }
500
- throw new Error(`Unknown option for analyze: ${arg}`);
501
1185
  }
502
1186
 
503
- return {};
1187
+ flush();
1188
+ return sections;
504
1189
  }
505
1190
 
506
- function getTargetConfig(rules, adapter) {
507
- const source = rules.targets && typeof rules.targets === "object" ? rules.targets[adapter.id] : null;
508
- return normalizeTargetConfig(source, adapter.defaultPath);
1191
+ function pickFirstNonEmptyLine(text) {
1192
+ for (const line of text.split("\n")) {
1193
+ const trimmed = line.trim();
1194
+ if (trimmed) {
1195
+ return trimmed;
1196
+ }
1197
+ }
1198
+ return "";
1199
+ }
1200
+
1201
+ function parseListItems(text) {
1202
+ const items = [];
1203
+ for (const line of text.split("\n")) {
1204
+ const bullet = line.match(/^\s*[-*]\s+(.+?)\s*$/);
1205
+ if (bullet) {
1206
+ items.push(bullet[1].trim());
1207
+ continue;
1208
+ }
1209
+ const numbered = line.match(/^\s*\d+\.\s+(.+?)\s*$/);
1210
+ if (numbered) {
1211
+ items.push(numbered[1].trim());
1212
+ }
1213
+ }
1214
+ return items;
1215
+ }
1216
+
1217
+ function unquoteValue(value) {
1218
+ let current = value.trim();
1219
+ if (current.startsWith("`") && current.endsWith("`")) {
1220
+ current = current.slice(1, -1);
1221
+ }
1222
+ current = stripQuotes(current);
1223
+ return current.trim();
1224
+ }
1225
+
1226
+ function importCommandsFromText(text, commands) {
1227
+ const merged = { ...commands };
1228
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
1229
+ const commandNames = Object.keys(merged);
1230
+
1231
+ for (const raw of lines) {
1232
+ const line = raw.trim();
1233
+ for (const name of commandNames) {
1234
+ const direct = line.match(new RegExp(`^(?:[-*]\\s*)?${name}\\s*:\\s*(.+)$`, "i"));
1235
+ if (direct && direct[1]) {
1236
+ merged[name] = unquoteValue(direct[1]);
1237
+ }
1238
+ }
1239
+ }
1240
+
1241
+ for (const name of commandNames) {
1242
+ if (!merged[name] || merged[name].includes("TODO")) {
1243
+ const found = text.match(new RegExp(`\\b(?:npm run|pnpm|yarn|bun)\\s+${name}\\b`, "i"));
1244
+ if (found) {
1245
+ merged[name] = found[0];
1246
+ }
1247
+ }
1248
+ }
1249
+
1250
+ return merged;
509
1251
  }
510
1252
 
511
- function initCommand(rootDir, logger) {
1253
+ function getSectionText(sections, aliases) {
1254
+ for (const alias of aliases) {
1255
+ const key = normalizeHeading(alias);
1256
+ if (sections[key] && sections[key].trim()) {
1257
+ return sections[key].trim();
1258
+ }
1259
+ }
1260
+ return "";
1261
+ }
1262
+
1263
+ function collectImportSources(rootDir) {
1264
+ const candidates = [
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" },
1271
+ ];
1272
+
1273
+ const uniquePaths = new Set();
1274
+ const sources = [];
1275
+
1276
+ for (const item of candidates) {
1277
+ if (uniquePaths.has(item.path)) {
1278
+ continue;
1279
+ }
1280
+ uniquePaths.add(item.path);
1281
+
1282
+ const absolutePath = resolveInRoot(rootDir, item.path);
1283
+ if (!existsSync(absolutePath)) {
1284
+ continue;
1285
+ }
1286
+ sources.push({
1287
+ path: item.path,
1288
+ text: readFileSync(absolutePath, "utf8"),
1289
+ });
1290
+ }
1291
+
1292
+ return sources;
1293
+ }
1294
+
1295
+ function importRulesFromDocs(rootDir, defaults) {
1296
+ const imported = JSON.parse(JSON.stringify(defaults));
1297
+ const sources = collectImportSources(rootDir);
1298
+ const notes = [];
1299
+
1300
+ if (sources.length === 0) {
1301
+ return {
1302
+ rules: imported,
1303
+ report: "No existing docs were found. Created default rules.",
1304
+ };
1305
+ }
1306
+
1307
+ notes.push(`Found ${sources.length} source file(s):`);
1308
+ for (const source of sources) {
1309
+ notes.push(`- ${source.path}`);
1310
+ }
1311
+
1312
+ for (const source of sources) {
1313
+ const sections = parseMarkdownSections(source.text);
1314
+
1315
+ const missionSection = getSectionText(sections, ["mission"]);
1316
+ if (missionSection) {
1317
+ const mission = pickFirstNonEmptyLine(missionSection);
1318
+ if (mission) {
1319
+ imported.mission = mission;
1320
+ }
1321
+ }
1322
+
1323
+ const workflowSection = getSectionText(sections, ["workflow", "operational loop"]);
1324
+ const workflow = parseListItems(workflowSection);
1325
+ if (workflow.length > 0) {
1326
+ imported.workflow = workflow;
1327
+ }
1328
+
1329
+ const doneSection = getSectionText(sections, ["done", "done criteria"]);
1330
+ const done = parseListItems(doneSection);
1331
+ if (done.length > 0) {
1332
+ imported.done = done;
1333
+ }
1334
+
1335
+ const approvalsSection = getSectionText(sections, ["approvals", "approval"]);
1336
+ if (approvalsSection) {
1337
+ const mode = approvalsSection.match(/(?:mode|policy)\s*:\s*`?([a-z0-9_-]+)`?/i);
1338
+ if (mode && mode[1]) {
1339
+ imported.approvals.mode = mode[1].trim();
1340
+ }
1341
+ const approvalNotes = parseListItems(approvalsSection);
1342
+ if (approvalNotes.length > 0) {
1343
+ imported.approvals.notes = approvalNotes;
1344
+ }
1345
+ }
1346
+
1347
+ imported.commands = importCommandsFromText(source.text, imported.commands);
1348
+ }
1349
+
1350
+ for (const adapter of ADAPTERS) {
1351
+ const config = getTargetConfig(imported, adapter);
1352
+ const absolutePath = resolveInRoot(rootDir, config.path);
1353
+ if (existsSync(absolutePath)) {
1354
+ imported.targets[adapter.id].enabled = true;
1355
+ }
1356
+ }
1357
+
1358
+ notes.push("Imported mission/workflow/commands/done/approvals where detected.");
1359
+ return {
1360
+ rules: imported,
1361
+ report: notes.join("\n"),
1362
+ };
1363
+ }
1364
+
1365
+ function initCommand(rootDir, logger, args) {
1366
+ const options = parseInitArgs(args);
512
1367
  const rulesFile = resolve(rootDir, RULES_RELATIVE_PATH);
513
1368
  if (existsSync(rulesFile)) {
514
1369
  logger.log(`rules.yaml already exists: ${rulesFile}`);
515
- return;
1370
+ return 0;
516
1371
  }
517
1372
 
518
1373
  const defaults = createDefaultRules(loadPackageScripts(rootDir));
1374
+ let rules = defaults;
1375
+ let importReport = "";
1376
+
1377
+ if (options.importExisting) {
1378
+ const imported = importRulesFromDocs(rootDir, defaults);
1379
+ rules = imported.rules;
1380
+ importReport = imported.report;
1381
+ }
1382
+
519
1383
  ensureParentDirectory(rulesFile);
520
- writeFileSync(rulesFile, stringifyRules(defaults), "utf8");
1384
+ writeFileSync(rulesFile, stringifyRules(rules), "utf8");
521
1385
  logger.log(`Created ${rulesFile}`);
1386
+
1387
+ if (options.importExisting) {
1388
+ const reportPath = resolve(rootDir, IMPORT_REPORT_RELATIVE_PATH);
1389
+ writeFileSync(reportPath, `${importReport}\n`, "utf8");
1390
+ logger.log(`Import report: ${reportPath}`);
1391
+ }
1392
+
1393
+ return 0;
522
1394
  }
523
1395
 
524
- function syncCommand(rootDir, logger, args) {
525
- const { rules } = loadRules(rootDir);
526
- const selectedTargetIds = parseSyncTargets(args);
1396
+ function buildTargetPlans(rootDir, rules, targetIds) {
1397
+ const plans = [];
527
1398
 
528
- let updated = 0;
529
- for (const targetId of selectedTargetIds) {
1399
+ for (const targetId of targetIds) {
530
1400
  const adapter = ADAPTERS_BY_ID[targetId];
531
1401
  const target = getTargetConfig(rules, adapter);
1402
+ const targetPath = resolveInRoot(rootDir, target.path);
1403
+ assertNoSymlinkTraversal(rootDir, targetPath);
1404
+ const fileExists = existsSync(targetPath);
1405
+ const currentText = fileExists ? readFileSync(targetPath, "utf8") : "";
532
1406
 
533
1407
  if (!target.enabled) {
534
- logger.log(`Skipped ${targetId} (disabled in rules.yaml).`);
1408
+ plans.push({
1409
+ targetId,
1410
+ adapter,
1411
+ enabled: false,
1412
+ targetPath,
1413
+ targetPathDisplay: target.path,
1414
+ exists: fileExists,
1415
+ currentText,
1416
+ desiredText: currentText,
1417
+ changed: false,
1418
+ });
535
1419
  continue;
536
1420
  }
537
1421
 
538
- const targetPath = resolveInRoot(rootDir, target.path);
539
1422
  const rendered = adapter.render(rules).trim();
540
- ensureParentDirectory(targetPath);
541
-
542
- if (adapter.management === "marker") {
543
- const existing = existsSync(targetPath) ? readFileSync(targetPath, "utf8") : "";
544
- const updatedText = upsertManagedSection(
545
- existing,
546
- rendered,
547
- adapter.markerBegin,
548
- adapter.markerEnd,
549
- );
550
- writeFileSync(targetPath, updatedText, "utf8");
551
- } else {
552
- writeFileSync(targetPath, `${rendered}\n`, "utf8");
553
- }
1423
+ const desiredText =
1424
+ adapter.management === "marker"
1425
+ ? upsertManagedSection(currentText, rendered, adapter.markerBegin, adapter.markerEnd)
1426
+ : `${rendered}\n`;
1427
+
1428
+ plans.push({
1429
+ targetId,
1430
+ adapter,
1431
+ enabled: true,
1432
+ targetPath,
1433
+ targetPathDisplay: target.path,
1434
+ exists: fileExists,
1435
+ currentText,
1436
+ desiredText,
1437
+ changed: desiredText !== currentText,
1438
+ });
1439
+ }
1440
+
1441
+ return plans;
1442
+ }
554
1443
 
555
- logger.log(`Updated ${targetPath} (${targetId})`);
556
- updated += 1;
1444
+ function renderSimpleDiff(currentText, desiredText) {
1445
+ if (currentText === desiredText) {
1446
+ return "";
557
1447
  }
558
1448
 
559
- if (updated === 0) {
560
- logger.log("No files updated.");
1449
+ const oldLines = currentText.replace(/\r\n/g, "\n").split("\n");
1450
+ const newLines = desiredText.replace(/\r\n/g, "\n").split("\n");
1451
+ const output = ["--- current", "+++ desired"];
1452
+ const maxLines = Math.max(oldLines.length, newLines.length);
1453
+ const hardLimit = 120;
1454
+ let emitted = 0;
1455
+
1456
+ for (let index = 0; index < maxLines; index += 1) {
1457
+ const oldLine = oldLines[index];
1458
+ const newLine = newLines[index];
1459
+
1460
+ if (oldLine === newLine) {
1461
+ continue;
1462
+ }
1463
+
1464
+ if (typeof oldLine !== "undefined") {
1465
+ output.push(`-${oldLine}`);
1466
+ emitted += 1;
1467
+ }
1468
+ if (typeof newLine !== "undefined") {
1469
+ output.push(`+${newLine}`);
1470
+ emitted += 1;
1471
+ }
1472
+
1473
+ if (emitted >= hardLimit) {
1474
+ output.push("... diff truncated ...");
1475
+ break;
1476
+ }
561
1477
  }
1478
+
1479
+ return output.join("\n");
562
1480
  }
563
1481
 
564
- function analyzeCommand(rootDir, logger, args) {
565
- const options = parseAnalyzeArgs(args);
566
- const { rules, rulesExists, rulesFile } = loadRules(rootDir, { allowMissing: true });
567
- const issues = [];
568
- const targetSnapshots = [];
1482
+ function formatPlanSummary(plans) {
1483
+ const changed = plans.filter((plan) => plan.changed).length;
1484
+ const changedFiles = new Set(plans.filter((plan) => plan.changed).map((plan) => plan.targetPath)).size;
1485
+ return { changed, changedFiles };
1486
+ }
569
1487
 
570
- logger.log("rules-doctor analyze");
571
- logger.log(`- rules.yaml: ${rulesExists ? "found" : "missing (using defaults)"}`);
572
- logger.log(`- rules path: ${rulesFile}`);
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
+ }
573
1499
 
574
- for (const adapter of ADAPTERS) {
575
- const target = getTargetConfig(rules, adapter);
576
- const absolutePath = resolveInRoot(rootDir, target.path);
577
- const fileExists = existsSync(absolutePath);
578
- const content = fileExists ? readFileSync(absolutePath, "utf8") : "";
1500
+ const shared = [];
1501
+ const conflicts = [];
1502
+ for (const plansAtPath of groups.values()) {
1503
+ if (plansAtPath.length < 2) {
1504
+ continue;
1505
+ }
579
1506
 
580
- logger.log(
581
- `- target ${adapter.id}: ${target.enabled ? "enabled" : "disabled"}, ${
582
- fileExists ? "found" : "missing"
583
- } (${target.path})`,
584
- );
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
+ }
585
1517
 
586
- if (!target.enabled) {
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") {
587
1537
  continue;
588
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 };
1552
+ }
1553
+
1554
+ function getUniqueWritePlans(plans) {
1555
+ const byPath = new Map();
1556
+ const duplicates = [];
589
1557
 
590
- if (!fileExists) {
591
- issues.push(`${adapter.id}: expected file is missing (${target.path}).`);
1558
+ for (const plan of plans) {
1559
+ if (!plan.enabled || !plan.changed) {
592
1560
  continue;
593
1561
  }
594
1562
 
595
- if (adapter.management === "marker") {
596
- const hasBegin = content.includes(adapter.markerBegin);
597
- const hasEnd = content.includes(adapter.markerEnd);
598
- if (!hasBegin || !hasEnd) {
599
- issues.push(`${adapter.id}: marker block is missing.`);
600
- }
1563
+ const existing = byPath.get(plan.targetPath);
1564
+ if (!existing) {
1565
+ byPath.set(plan.targetPath, plan);
1566
+ continue;
601
1567
  }
602
1568
 
603
- if (!hasVerifyCommand(content)) {
604
- issues.push(`${adapter.id}: verify commands (lint/test/build) not detected.`);
1569
+ if (existing.desiredText !== plan.desiredText) {
1570
+ throw new Error(
1571
+ `Conflicting outputs for ${plan.targetPathDisplay}: ${existing.targetId} and ${plan.targetId} produce different content.`,
1572
+ );
605
1573
  }
606
1574
 
607
- targetSnapshots.push({
608
- id: adapter.id,
609
- content,
610
- asksApproval: hasAskApprovalLanguage(content),
611
- noApproval: hasNoApprovalLanguage(content),
612
- requiresTests: hasRequireTestsLanguage(content),
613
- skipsTests: hasSkipTestsLanguage(content),
1575
+ duplicates.push({
1576
+ targetPathDisplay: plan.targetPathDisplay,
1577
+ winner: existing.targetId,
1578
+ duplicate: plan.targetId,
614
1579
  });
615
1580
  }
616
1581
 
617
- const askApprovalTargets = targetSnapshots.filter((item) => item.asksApproval).map((item) => item.id);
618
- const noApprovalTargets = targetSnapshots.filter((item) => item.noApproval).map((item) => item.id);
619
- if (askApprovalTargets.length > 0 && noApprovalTargets.length > 0) {
620
- issues.push(
621
- `Potential contradiction: approval guidance differs (${askApprovalTargets.join(
622
- ", ",
623
- )} vs ${noApprovalTargets.join(", ")}).`,
624
- );
1582
+ return {
1583
+ uniquePlans: Array.from(byPath.values()),
1584
+ duplicates,
1585
+ };
1586
+ }
1587
+
1588
+ function syncCommand(rootDir, logger, args) {
1589
+ const options = parseTargetedArgs("sync", args, { write: true, backup: true });
1590
+ const { rules } = loadRules(rootDir, { logger });
1591
+ const plans = buildTargetPlans(rootDir, rules, options.targetIds);
1592
+ const summary = formatPlanSummary(plans);
1593
+
1594
+ logger.log("rules-doctor sync");
1595
+ logger.log(`- root: ${rootDir}`);
1596
+ logger.log(`- selected targets: ${options.targetIds.join(", ")}`);
1597
+ logger.log(`- mode: ${options.write ? "write" : "dry-run"}`);
1598
+
1599
+ for (const plan of plans) {
1600
+ if (!plan.enabled) {
1601
+ logger.log(`- ${plan.targetId}: disabled (${plan.targetPathDisplay})`);
1602
+ continue;
1603
+ }
1604
+ if (!plan.changed) {
1605
+ logger.log(`- ${plan.targetId}: up-to-date (${plan.targetPathDisplay})`);
1606
+ continue;
1607
+ }
1608
+ logger.log(`- ${plan.targetId}: would update (${plan.targetPathDisplay})`);
625
1609
  }
626
1610
 
627
- const requireTestsTargets = targetSnapshots
628
- .filter((item) => item.requiresTests)
629
- .map((item) => item.id);
630
- const skipTestsTargets = targetSnapshots.filter((item) => item.skipsTests).map((item) => item.id);
631
- if (requireTestsTargets.length > 0 && skipTestsTargets.length > 0) {
632
- issues.push(
633
- `Potential contradiction: test guidance differs (${requireTestsTargets.join(
634
- ", ",
635
- )} vs ${skipTestsTargets.join(", ")}).`,
636
- );
1611
+ if (options.diff) {
1612
+ for (const plan of plans) {
1613
+ if (!plan.enabled || !plan.changed) {
1614
+ continue;
1615
+ }
1616
+ logger.log(`\n# diff: ${plan.targetId} (${plan.targetPathDisplay})`);
1617
+ logger.log(renderSimpleDiff(plan.currentText, plan.desiredText));
1618
+ }
1619
+ }
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.");
637
1634
  }
638
1635
 
639
- logger.log("- Findings:");
640
- if (issues.length === 0) {
641
- logger.log("- No obvious issues found.");
1636
+ if (!options.write) {
1637
+ if (summary.changed === 0) {
1638
+ logger.log("Dry-run complete: no changes.");
1639
+ } else {
1640
+ logger.log(
1641
+ `Dry-run complete: ${summary.changedFiles} file(s) would change (${summary.changed} target mappings). Re-run with --write.`,
1642
+ );
1643
+ }
642
1644
  return 0;
643
1645
  }
644
1646
 
645
- for (const issue of issues) {
646
- logger.log(`- ${issue}`);
1647
+ const { uniquePlans, duplicates } = getUniqueWritePlans(plans);
1648
+ for (const duplicate of duplicates) {
1649
+ logger.log(
1650
+ ` note: ${duplicate.duplicate} shares output with ${duplicate.winner} at ${duplicate.targetPathDisplay}`,
1651
+ );
647
1652
  }
648
1653
 
649
- if (options.strict) {
650
- throw new Error(`Analyze failed in strict mode with ${issues.length} issue(s).`);
1654
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
1655
+ for (const plan of uniquePlans) {
1656
+ ensureParentDirectory(plan.targetPath);
1657
+ if (options.backup && plan.exists) {
1658
+ const backupPath = `${plan.targetPath}.rules-doctor.bak.${timestamp}`;
1659
+ writeFileSync(backupPath, plan.currentText, "utf8");
1660
+ logger.log(` backup: ${backupPath}`);
1661
+ }
1662
+
1663
+ writeFileSync(plan.targetPath, plan.desiredText, "utf8");
1664
+ logger.log(` updated: ${plan.targetPath}`);
651
1665
  }
652
1666
 
1667
+ logger.log(
1668
+ `Write complete: ${uniquePlans.length} file(s) updated (${summary.changed} target mappings changed).`,
1669
+ );
653
1670
  return 0;
654
1671
  }
655
1672
 
656
- function targetsListCommand(logger) {
657
- logger.log("Supported targets:");
658
- for (const adapter of ADAPTERS) {
659
- const mode = adapter.management === "marker" ? "marker-managed" : "full-managed";
660
- logger.log(`- ${adapter.id}: ${adapter.name}`);
661
- logger.log(` default path: ${adapter.defaultPath}`);
662
- logger.log(` mode: ${mode}`);
663
- logger.log(` ${adapter.description}`);
1673
+ function checkCommand(rootDir, logger, args) {
1674
+ const options = parseTargetedArgs("check", args, { write: false, backup: false });
1675
+ const { rules } = loadRules(rootDir, { logger });
1676
+ const plans = buildTargetPlans(rootDir, rules, options.targetIds);
1677
+ const summary = formatPlanSummary(plans);
1678
+
1679
+ logger.log("rules-doctor check");
1680
+ logger.log(`- root: ${rootDir}`);
1681
+ logger.log(`- selected targets: ${options.targetIds.join(", ")}`);
1682
+
1683
+ for (const plan of plans) {
1684
+ if (!plan.enabled) {
1685
+ logger.log(`- ${plan.targetId}: disabled (${plan.targetPathDisplay})`);
1686
+ continue;
1687
+ }
1688
+ logger.log(
1689
+ `- ${plan.targetId}: ${plan.changed ? "drift detected" : "in sync"} (${plan.targetPathDisplay})`,
1690
+ );
1691
+ }
1692
+
1693
+ if (options.diff) {
1694
+ for (const plan of plans) {
1695
+ if (!plan.enabled || !plan.changed) {
1696
+ continue;
1697
+ }
1698
+ logger.log(`\n# diff: ${plan.targetId} (${plan.targetPathDisplay})`);
1699
+ logger.log(renderSimpleDiff(plan.currentText, plan.desiredText));
1700
+ }
664
1701
  }
1702
+
1703
+ if (summary.changed === 0) {
1704
+ logger.log("Check complete: all selected targets are in sync.");
1705
+ return 0;
1706
+ }
1707
+
1708
+ logger.log(`Check failed: ${summary.changed} target file(s) need sync.`);
1709
+ return 1;
665
1710
  }
666
1711
 
667
1712
  function runCli(argv, options) {
@@ -679,25 +1724,15 @@ function runCli(argv, options) {
679
1724
  }
680
1725
 
681
1726
  if (command === "init") {
682
- initCommand(rootDir, logger);
683
- return 0;
1727
+ return initCommand(rootDir, logger, rest);
684
1728
  }
685
1729
 
686
1730
  if (command === "sync") {
687
- syncCommand(rootDir, logger, rest);
688
- return 0;
1731
+ return syncCommand(rootDir, logger, rest);
689
1732
  }
690
1733
 
691
- if (command === "analyze") {
692
- return analyzeCommand(rootDir, logger, rest);
693
- }
694
-
695
- if (command === "targets") {
696
- if (rest.length === 1 && rest[0] === "list") {
697
- targetsListCommand(logger);
698
- return 0;
699
- }
700
- throw new Error('Unknown targets command. Use "rules-doctor targets list".');
1734
+ if (command === "check") {
1735
+ return checkCommand(rootDir, logger, rest);
701
1736
  }
702
1737
 
703
1738
  throw new Error(`Unknown command: ${command}\n\n${usage()}`);