@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.
- package/README.md +1 -42
- package/dist/index.js +774 -438
- 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
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
396
|
+
const top = splitKeyValueLine(line);
|
|
114
397
|
if (!top) {
|
|
115
398
|
continue;
|
|
116
399
|
}
|
|
117
400
|
|
|
118
|
-
const key = top[
|
|
119
|
-
const value = top[
|
|
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
|
-
|
|
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
|
|
430
|
+
const pair = splitKeyValueLine(line);
|
|
142
431
|
if (pair) {
|
|
143
|
-
data.commands[pair[
|
|
432
|
+
data.commands[pair[0]] = parseScalar(pair[1]);
|
|
144
433
|
}
|
|
145
434
|
continue;
|
|
146
435
|
}
|
|
147
436
|
|
|
148
437
|
if (section === "approvals") {
|
|
149
|
-
|
|
150
|
-
|
|
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 (
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
...
|
|
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
|
|
394
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
404
|
-
|
|
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
|
-
|
|
408
|
-
return /must run tests|always run tests|run tests before done/i.test(text);
|
|
869
|
+
return resolvedPath;
|
|
409
870
|
}
|
|
410
871
|
|
|
411
|
-
function
|
|
412
|
-
|
|
413
|
-
|
|
872
|
+
function assertNoSymlinkTraversal(rootDir, targetPath) {
|
|
873
|
+
const rel = relative(rootDir, targetPath);
|
|
874
|
+
if (!rel || rel === ".") {
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
414
877
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
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
|
|
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}${
|
|
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
|
|
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
|
-
{
|
|
784
|
-
{
|
|
785
|
-
{
|
|
786
|
-
{
|
|
787
|
-
{
|
|
788
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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);
|