@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/CHANGELOG.md +18 -0
- package/README.md +85 -96
- package/dist/adapters/copilot.js +15 -0
- package/dist/adapters/index.js +2 -2
- package/dist/index.js +1246 -211
- package/package.json +8 -4
- package/dist/adapters/antigravity.js +0 -12
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
|
-
|
|
17
|
-
"
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
396
|
+
const top = splitKeyValueLine(line);
|
|
105
397
|
if (!top) {
|
|
106
398
|
continue;
|
|
107
399
|
}
|
|
108
400
|
|
|
109
|
-
const key = top[
|
|
110
|
-
const value = top[
|
|
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
|
-
|
|
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
|
|
430
|
+
const pair = splitKeyValueLine(line);
|
|
133
431
|
if (pair) {
|
|
134
|
-
data.commands[pair[
|
|
432
|
+
data.commands[pair[0]] = parseScalar(pair[1]);
|
|
135
433
|
}
|
|
136
434
|
continue;
|
|
137
435
|
}
|
|
138
436
|
|
|
139
437
|
if (section === "approvals") {
|
|
140
|
-
|
|
141
|
-
|
|
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 (
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
...
|
|
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
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
354
|
-
|
|
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
|
-
|
|
358
|
-
return /must run tests|always run tests|run tests before done/i.test(text);
|
|
869
|
+
return resolvedPath;
|
|
359
870
|
}
|
|
360
871
|
|
|
361
|
-
function
|
|
362
|
-
|
|
363
|
-
|
|
872
|
+
function assertNoSymlinkTraversal(rootDir, targetPath) {
|
|
873
|
+
const rel = relative(rootDir, targetPath);
|
|
874
|
+
if (!rel || rel === ".") {
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
364
877
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
|
394
|
-
|
|
395
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
|
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}${
|
|
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
|
|
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
|
|
468
|
-
|
|
469
|
-
|
|
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
|
-
|
|
473
|
-
|
|
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(
|
|
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
|
-
|
|
1136
|
+
if (options.backup && !options.write) {
|
|
1137
|
+
throw new Error("--backup requires --write.");
|
|
486
1138
|
}
|
|
487
1139
|
|
|
488
|
-
return
|
|
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
|
|
492
|
-
|
|
493
|
-
|
|
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
|
|
497
|
-
|
|
498
|
-
|
|
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
|
-
|
|
1187
|
+
flush();
|
|
1188
|
+
return sections;
|
|
504
1189
|
}
|
|
505
1190
|
|
|
506
|
-
function
|
|
507
|
-
const
|
|
508
|
-
|
|
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
|
|
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(
|
|
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
|
|
525
|
-
const
|
|
526
|
-
const selectedTargetIds = parseSyncTargets(args);
|
|
1396
|
+
function buildTargetPlans(rootDir, rules, targetIds) {
|
|
1397
|
+
const plans = [];
|
|
527
1398
|
|
|
528
|
-
|
|
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
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
556
|
-
|
|
1444
|
+
function renderSimpleDiff(currentText, desiredText) {
|
|
1445
|
+
if (currentText === desiredText) {
|
|
1446
|
+
return "";
|
|
557
1447
|
}
|
|
558
1448
|
|
|
559
|
-
|
|
560
|
-
|
|
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
|
|
565
|
-
const
|
|
566
|
-
const
|
|
567
|
-
|
|
568
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
1500
|
+
const shared = [];
|
|
1501
|
+
const conflicts = [];
|
|
1502
|
+
for (const plansAtPath of groups.values()) {
|
|
1503
|
+
if (plansAtPath.length < 2) {
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
579
1506
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
-
|
|
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
|
-
|
|
591
|
-
|
|
1558
|
+
for (const plan of plans) {
|
|
1559
|
+
if (!plan.enabled || !plan.changed) {
|
|
592
1560
|
continue;
|
|
593
1561
|
}
|
|
594
1562
|
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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 (
|
|
604
|
-
|
|
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
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
646
|
-
|
|
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
|
-
|
|
650
|
-
|
|
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
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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 === "
|
|
692
|
-
return
|
|
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()}`);
|