rulesync 16.2.0 → 16.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const require_import = require("../import-DNfxS6QZ.cjs");
2
+ const require_import = require("../import-DD-GRmB9.cjs");
3
3
  let commander = require("commander");
4
4
  let zod_mini = require("zod/mini");
5
5
  let node_fs_promises = require("node:fs/promises");
@@ -3861,6 +3861,564 @@ async function convertCommand(logger, options) {
3861
3861
  else logger.success(summary);
3862
3862
  }
3863
3863
  //#endregion
3864
+ //#region src/cli/commands/doctor.ts
3865
+ /** Per-feature object form also accepts a gitignore destination override. */
3866
+ const PER_FEATURE_EXTRA_KEYS = [require_import.GITIGNORE_DESTINATION_KEY];
3867
+ const KNOWN_CONFIG_KEYS = Object.keys(require_import.ConfigFileSchema.shape);
3868
+ /**
3869
+ * Classic dynamic-programming Levenshtein distance; inputs are short config
3870
+ * keys and tool names, so the O(a.b) cost is negligible.
3871
+ */
3872
+ function levenshteinDistance({ a, b }) {
3873
+ const rows = a.length + 1;
3874
+ const cols = b.length + 1;
3875
+ let previous = Array.from({ length: cols }, (_, i) => i);
3876
+ for (let i = 1; i < rows; i++) {
3877
+ const current = [i, ...Array.from({ length: cols - 1 }, () => 0)];
3878
+ for (let j = 1; j < cols; j++) {
3879
+ const substitutionCost = a[i - 1] === b[j - 1] ? 0 : 1;
3880
+ current[j] = Math.min((previous[j] ?? 0) + 1, (current[j - 1] ?? 0) + 1, (previous[j - 1] ?? 0) + substitutionCost);
3881
+ }
3882
+ previous = current;
3883
+ }
3884
+ return previous[cols - 1] ?? 0;
3885
+ }
3886
+ /**
3887
+ * Returns the closest candidate to `input`, or undefined when nothing is close
3888
+ * enough to be a plausible typo. The threshold scales with input length so
3889
+ * short keys don't produce far-fetched suggestions.
3890
+ */
3891
+ function suggestNearest({ input, candidates }) {
3892
+ const maxDistance = Math.max(2, Math.floor(input.length / 3));
3893
+ let best;
3894
+ let bestDistance = Number.POSITIVE_INFINITY;
3895
+ for (const candidate of candidates) {
3896
+ const distance = levenshteinDistance({
3897
+ a: input.toLowerCase(),
3898
+ b: candidate.toLowerCase()
3899
+ });
3900
+ if (distance < bestDistance) {
3901
+ bestDistance = distance;
3902
+ best = candidate;
3903
+ }
3904
+ }
3905
+ return bestDistance <= maxDistance ? best : void 0;
3906
+ }
3907
+ function didYouMean({ input, candidates }) {
3908
+ const suggestion = suggestNearest({
3909
+ input,
3910
+ candidates
3911
+ });
3912
+ return suggestion === void 0 ? void 0 : `Did you mean '${suggestion}'?`;
3913
+ }
3914
+ /** Converts a character offset into a 1-based line/column pair. */
3915
+ function offsetToPosition({ content, offset }) {
3916
+ let line = 1;
3917
+ let lineStart = 0;
3918
+ const end = Math.min(offset, content.length);
3919
+ for (let i = 0; i < end; i++) if (content[i] === "\n") {
3920
+ line++;
3921
+ lineStart = i + 1;
3922
+ }
3923
+ return {
3924
+ line,
3925
+ column: end - lineStart + 1
3926
+ };
3927
+ }
3928
+ function isPlainObject(value) {
3929
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3930
+ }
3931
+ function checkTargetName({ name, file, context }) {
3932
+ if (require_import.ALL_TOOL_TARGETS.includes(name)) return void 0;
3933
+ return {
3934
+ severity: "error",
3935
+ code: "config/unknown-target",
3936
+ file,
3937
+ message: `Unknown tool target '${name}' in ${context}.`,
3938
+ hint: didYouMean({
3939
+ input: name,
3940
+ candidates: require_import.ALL_TOOL_TARGETS
3941
+ }) ?? `Valid targets: ${require_import.ALL_TOOL_TARGETS.join(", ")}.`
3942
+ };
3943
+ }
3944
+ function checkFeatureName({ name, file, context }) {
3945
+ const replacement = require_import.DEPRECATED_FEATURE_REPLACEMENTS[name];
3946
+ if (replacement !== void 0) return {
3947
+ severity: "warning",
3948
+ code: "config/deprecated-feature",
3949
+ file,
3950
+ message: `Feature '${name}' in ${context} is deprecated.`,
3951
+ hint: `Use the '${replacement}' feature instead.`
3952
+ };
3953
+ if (require_import.ALL_FEATURES.includes(name)) return void 0;
3954
+ return {
3955
+ severity: "error",
3956
+ code: "config/unknown-feature",
3957
+ file,
3958
+ message: `Unknown feature '${name}' in ${context}.`,
3959
+ hint: didYouMean({
3960
+ input: name,
3961
+ candidates: require_import.ALL_FEATURES
3962
+ }) ?? `Valid features: ${require_import.ALL_FEATURES.join(", ")}.`
3963
+ };
3964
+ }
3965
+ function checkTargetsValue({ targets, file }) {
3966
+ const diagnostics = [];
3967
+ if (Array.isArray(targets)) {
3968
+ for (const entry of targets) {
3969
+ if (typeof entry !== "string") {
3970
+ diagnostics.push({
3971
+ severity: "error",
3972
+ code: "config/invalid-value",
3973
+ file,
3974
+ message: `'targets' entries must be strings, found ${JSON.stringify(entry)}.`
3975
+ });
3976
+ continue;
3977
+ }
3978
+ if (entry === "*") continue;
3979
+ const diagnostic = checkTargetName({
3980
+ name: entry,
3981
+ file,
3982
+ context: "'targets'"
3983
+ });
3984
+ if (diagnostic) diagnostics.push(diagnostic);
3985
+ }
3986
+ return diagnostics;
3987
+ }
3988
+ if (isPlainObject(targets)) {
3989
+ for (const [key, value] of Object.entries(targets)) {
3990
+ if (key === "*") {
3991
+ diagnostics.push({
3992
+ severity: "error",
3993
+ code: "config/invalid-value",
3994
+ file,
3995
+ message: "Wildcard '*' is not supported as a key in the object form of 'targets'; per-target options cannot be attached to a wildcard.",
3996
+ hint: "Use the array form `\"targets\": [\"*\"]` instead."
3997
+ });
3998
+ continue;
3999
+ }
4000
+ const diagnostic = checkTargetName({
4001
+ name: key,
4002
+ file,
4003
+ context: "the 'targets' object"
4004
+ });
4005
+ if (diagnostic) {
4006
+ diagnostics.push(diagnostic);
4007
+ continue;
4008
+ }
4009
+ diagnostics.push(...checkPerTargetFeaturesValue({
4010
+ target: key,
4011
+ value,
4012
+ file
4013
+ }));
4014
+ }
4015
+ return diagnostics;
4016
+ }
4017
+ if (targets !== void 0) diagnostics.push({
4018
+ severity: "error",
4019
+ code: "config/invalid-value",
4020
+ file,
4021
+ message: `'targets' must be an array of tool names or a per-target object, found ${JSON.stringify(targets)}.`
4022
+ });
4023
+ return diagnostics;
4024
+ }
4025
+ function checkPerTargetFeaturesValue({ target, value, file }) {
4026
+ const diagnostics = [];
4027
+ if (Array.isArray(value)) {
4028
+ for (const entry of value) {
4029
+ if (typeof entry !== "string") {
4030
+ diagnostics.push({
4031
+ severity: "error",
4032
+ code: "config/invalid-value",
4033
+ file,
4034
+ message: `Features for target '${target}' must be strings, found ${JSON.stringify(entry)}.`
4035
+ });
4036
+ continue;
4037
+ }
4038
+ if (entry === "*") continue;
4039
+ const diagnostic = checkFeatureName({
4040
+ name: entry,
4041
+ file,
4042
+ context: `'targets.${target}'`
4043
+ });
4044
+ if (diagnostic) diagnostics.push(diagnostic);
4045
+ }
4046
+ return diagnostics;
4047
+ }
4048
+ if (isPlainObject(value)) {
4049
+ for (const key of Object.keys(value)) {
4050
+ if (key === "*" || PER_FEATURE_EXTRA_KEYS.includes(key)) continue;
4051
+ const diagnostic = checkFeatureName({
4052
+ name: key,
4053
+ file,
4054
+ context: `'targets.${target}'`
4055
+ });
4056
+ if (diagnostic) diagnostics.push(diagnostic);
4057
+ }
4058
+ return diagnostics;
4059
+ }
4060
+ diagnostics.push({
4061
+ severity: "error",
4062
+ code: "config/invalid-value",
4063
+ file,
4064
+ message: `Value for target '${target}' must be a feature array or a per-feature object, found ${JSON.stringify(value)}.`
4065
+ });
4066
+ return diagnostics;
4067
+ }
4068
+ function checkFeaturesValue({ features, file }) {
4069
+ const diagnostics = [];
4070
+ if (features === void 0) return diagnostics;
4071
+ if (!Array.isArray(features)) {
4072
+ diagnostics.push({
4073
+ severity: "error",
4074
+ code: "config/invalid-value",
4075
+ file,
4076
+ message: `'features' must be an array of feature names, found ${JSON.stringify(features)}.`,
4077
+ hint: "To configure features per target, use the object form of 'targets' instead."
4078
+ });
4079
+ return diagnostics;
4080
+ }
4081
+ for (const entry of features) {
4082
+ if (typeof entry !== "string") {
4083
+ diagnostics.push({
4084
+ severity: "error",
4085
+ code: "config/invalid-value",
4086
+ file,
4087
+ message: `'features' entries must be strings, found ${JSON.stringify(entry)}.`
4088
+ });
4089
+ continue;
4090
+ }
4091
+ if (entry === "*") continue;
4092
+ const diagnostic = checkFeatureName({
4093
+ name: entry,
4094
+ file,
4095
+ context: "'features'"
4096
+ });
4097
+ if (diagnostic) diagnostics.push(diagnostic);
4098
+ }
4099
+ return diagnostics;
4100
+ }
4101
+ function checkConflictingTargets({ targets, file }) {
4102
+ const has = (target) => {
4103
+ if (Array.isArray(targets)) return targets.includes(target);
4104
+ if (isPlainObject(targets)) return Object.prototype.hasOwnProperty.call(targets, target);
4105
+ return false;
4106
+ };
4107
+ const diagnostics = [];
4108
+ for (const [target1, target2] of require_import.CONFLICTING_TARGET_PAIRS) if (has(target1) && has(target2)) diagnostics.push({
4109
+ severity: "error",
4110
+ code: "config/conflicting-targets",
4111
+ file,
4112
+ message: `Targets '${target1}' and '${target2}' cannot be used together.`,
4113
+ hint: "Remove one of the two from 'targets'."
4114
+ });
4115
+ return diagnostics;
4116
+ }
4117
+ function checkSchemaProperty({ config, file }) {
4118
+ const schema = config.$schema;
4119
+ if (schema === void 0) return [{
4120
+ severity: "info",
4121
+ code: "config/missing-schema",
4122
+ file,
4123
+ message: "No '$schema' property; editors cannot offer completion and validation.",
4124
+ hint: `Add "$schema": "${require_import.RULESYNC_CONFIG_SCHEMA_URL}".`
4125
+ }];
4126
+ if (typeof schema === "string" && schema !== "https://github.com/dyoshikawa/rulesync/releases/latest/download/config-schema.json") return [{
4127
+ severity: "warning",
4128
+ code: "config/outdated-schema",
4129
+ file,
4130
+ message: `'$schema' does not point at the current rulesync config schema.`,
4131
+ hint: `Update it to "${require_import.RULESYNC_CONFIG_SCHEMA_URL}".`
4132
+ }];
4133
+ return [];
4134
+ }
4135
+ function checkUnknownTopLevelKeys({ config, file }) {
4136
+ const diagnostics = [];
4137
+ for (const key of Object.keys(config)) {
4138
+ if (KNOWN_CONFIG_KEYS.includes(key)) continue;
4139
+ diagnostics.push({
4140
+ severity: "error",
4141
+ code: "config/unknown-key",
4142
+ file,
4143
+ message: `Unknown key '${key}'. It is silently ignored by 'rulesync generate'.`,
4144
+ hint: didYouMean({
4145
+ input: key,
4146
+ candidates: KNOWN_CONFIG_KEYS
4147
+ }) ?? `Known keys: ${KNOWN_CONFIG_KEYS.join(", ")}.`
4148
+ });
4149
+ }
4150
+ return diagnostics;
4151
+ }
4152
+ function checkTargetsFeaturesExclusivity({ config, file }) {
4153
+ if (!isPlainObject(config.targets) || config.features === void 0) return [];
4154
+ return [{
4155
+ severity: "error",
4156
+ code: "config/targets-features-conflict",
4157
+ file,
4158
+ message: "When 'targets' is in object form, 'features' must be omitted.",
4159
+ hint: "Declare per-target features inside the 'targets' object instead."
4160
+ }];
4161
+ }
4162
+ function checkTokenEnvVars({ config, file, env }) {
4163
+ if (!Array.isArray(config.sources)) return [];
4164
+ const diagnostics = [];
4165
+ for (const source of config.sources) {
4166
+ if (!isPlainObject(source)) continue;
4167
+ const tokenEnv = source.tokenEnv;
4168
+ if (typeof tokenEnv !== "string" || tokenEnv.length === 0) continue;
4169
+ if (env[tokenEnv] === void 0 || env[tokenEnv] === "") diagnostics.push({
4170
+ severity: "warning",
4171
+ code: "config/token-env-not-set",
4172
+ file,
4173
+ message: `Source '${String(source.source ?? "<unnamed>")}' references environment variable '${tokenEnv}', which is not set.`,
4174
+ hint: `Export ${tokenEnv} before running commands that fetch from this source.`
4175
+ });
4176
+ }
4177
+ return diagnostics;
4178
+ }
4179
+ /**
4180
+ * Structural validation via the same Zod schema `ConfigResolver` uses.
4181
+ * `targets` / `features` issues are skipped because the dedicated checks above
4182
+ * already reported them with better messages and suggestions.
4183
+ */
4184
+ function checkAgainstConfigFileSchema({ config, file }) {
4185
+ const result = require_import.ConfigFileSchema.safeParse(config);
4186
+ if (result.success) return [];
4187
+ const diagnostics = [];
4188
+ for (const issue of result.error.issues) {
4189
+ const topLevelKey = issue.path[0];
4190
+ if (topLevelKey === "targets" || topLevelKey === "features") continue;
4191
+ const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
4192
+ diagnostics.push({
4193
+ severity: "error",
4194
+ code: "config/invalid-value",
4195
+ file,
4196
+ message: `Invalid value at '${path}': ${issue.message}`
4197
+ });
4198
+ }
4199
+ return diagnostics;
4200
+ }
4201
+ /**
4202
+ * Runs every per-file check against one configuration file's raw content.
4203
+ * Pure with respect to the filesystem so each check is unit-testable; only the
4204
+ * `tokenEnv` check consults the provided environment map.
4205
+ */
4206
+ function collectConfigFileDiagnostics({ file, content, env = process.env }) {
4207
+ if (content.trim() === "") return [{
4208
+ severity: "warning",
4209
+ code: "config/empty-file",
4210
+ file,
4211
+ message: "Configuration file is empty; rulesync will run with built-in defaults."
4212
+ }];
4213
+ const parseErrors = [];
4214
+ const parsed = (0, jsonc_parser.parse)(content, parseErrors, { allowTrailingComma: true });
4215
+ if (parseErrors.length > 0) return parseErrors.map((parseError) => {
4216
+ const { line, column } = offsetToPosition({
4217
+ content,
4218
+ offset: parseError.offset
4219
+ });
4220
+ return {
4221
+ severity: "error",
4222
+ code: "config/parse-error",
4223
+ file,
4224
+ message: `JSONC parse error: ${(0, jsonc_parser.printParseErrorCode)(parseError.error)}.`,
4225
+ line,
4226
+ column
4227
+ };
4228
+ });
4229
+ if (!isPlainObject(parsed)) return [{
4230
+ severity: "error",
4231
+ code: "config/not-an-object",
4232
+ file,
4233
+ message: `Configuration file must contain a JSON object, found ${JSON.stringify(parsed)}.`
4234
+ }];
4235
+ return [
4236
+ ...checkUnknownTopLevelKeys({
4237
+ config: parsed,
4238
+ file
4239
+ }),
4240
+ ...checkSchemaProperty({
4241
+ config: parsed,
4242
+ file
4243
+ }),
4244
+ ...checkTargetsValue({
4245
+ targets: parsed.targets,
4246
+ file
4247
+ }),
4248
+ ...checkFeaturesValue({
4249
+ features: parsed.features,
4250
+ file
4251
+ }),
4252
+ ...checkTargetsFeaturesExclusivity({
4253
+ config: parsed,
4254
+ file
4255
+ }),
4256
+ ...checkConflictingTargets({
4257
+ targets: parsed.targets,
4258
+ file
4259
+ }),
4260
+ ...checkTokenEnvVars({
4261
+ config: parsed,
4262
+ file,
4263
+ env
4264
+ }),
4265
+ ...checkAgainstConfigFileSchema({
4266
+ config: parsed,
4267
+ file
4268
+ })
4269
+ ];
4270
+ }
4271
+ /**
4272
+ * A base file and a local file can each be valid in isolation yet merge into
4273
+ * the invalid `{ targets: object, features: array }` state — the same
4274
+ * cross-file rule `ConfigResolver` enforces at generate time.
4275
+ */
4276
+ function collectMergedConfigDiagnostics({ baseConfig, localConfig, baseFile, localFile }) {
4277
+ if (baseConfig === void 0 || localConfig === void 0) return [];
4278
+ const mergedTargets = localConfig.targets ?? baseConfig.targets;
4279
+ const mergedFeatures = localConfig.features ?? baseConfig.features;
4280
+ if (!isPlainObject(mergedTargets) || mergedFeatures === void 0) return [];
4281
+ if (isPlainObject(baseConfig.targets) && baseConfig.features !== void 0 || isPlainObject(localConfig.targets) && localConfig.features !== void 0) return [];
4282
+ return [{
4283
+ severity: "error",
4284
+ code: "config/targets-features-conflict",
4285
+ file: localFile,
4286
+ message: `Merging '${baseFile}' with '${localFile}' combines object-form 'targets' with 'features', which is invalid.`,
4287
+ hint: "Remove the conflicting field from one of the two files."
4288
+ }];
4289
+ }
4290
+ function severityRank(severity) {
4291
+ return severity === "error" ? 0 : severity === "warning" ? 1 : 2;
4292
+ }
4293
+ /**
4294
+ * Strips control characters (including ANSI escape sequences) so key names and
4295
+ * values copied out of an untrusted config file cannot inject terminal escape
4296
+ * codes into the diagnostic output.
4297
+ */
4298
+ function stripControlCharacters(text) {
4299
+ return text.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, "");
4300
+ }
4301
+ function formatDiagnostic(diagnostic) {
4302
+ const position = diagnostic.line !== void 0 ? `:${diagnostic.line}${diagnostic.column !== void 0 ? `:${diagnostic.column}` : ""}` : "";
4303
+ const label = diagnostic.severity === "error" ? "✖" : diagnostic.severity === "warning" ? "⚠" : "ℹ";
4304
+ const hint = diagnostic.hint === void 0 ? "" : `\n ↳ ${stripControlCharacters(diagnostic.hint)}`;
4305
+ return `${label} ${stripControlCharacters(diagnostic.file)}${position} [${diagnostic.code}] ${stripControlCharacters(diagnostic.message)}${hint}`;
4306
+ }
4307
+ /**
4308
+ * Re-parses an already-read config file's content for the cross-file checks.
4309
+ * Returns undefined when the content is unparseable or not an object — the
4310
+ * per-file checks have already reported those states.
4311
+ */
4312
+ function parseConfigObjectForMerge(content) {
4313
+ if (content === void 0) return void 0;
4314
+ const errors = [];
4315
+ const parsed = (0, jsonc_parser.parse)(content, errors, { allowTrailingComma: true });
4316
+ if (errors.length > 0 || !isPlainObject(parsed)) return void 0;
4317
+ return parsed;
4318
+ }
4319
+ /**
4320
+ * Path sanity: an `inputRoot` that does not exist means every command will
4321
+ * fail (or silently read nothing); local config wins, mirroring the merge
4322
+ * order in `ConfigResolver`.
4323
+ */
4324
+ async function checkInputRootExists({ baseConfig, localConfig, baseFile, localFile }) {
4325
+ const mergedInputRoot = localConfig?.inputRoot ?? baseConfig?.inputRoot;
4326
+ if (typeof mergedInputRoot !== "string" || mergedInputRoot.length === 0) return [];
4327
+ if (await require_import.directoryExists((0, node_path.resolve)(mergedInputRoot))) return [];
4328
+ return [{
4329
+ severity: "error",
4330
+ code: "config/input-root-not-found",
4331
+ file: localConfig?.inputRoot !== void 0 ? localFile : baseFile,
4332
+ message: `'inputRoot' points at '${mergedInputRoot}', which is not an existing directory.`,
4333
+ hint: "Create the directory or fix the 'inputRoot' path."
4334
+ }];
4335
+ }
4336
+ function reportDiagnostics({ logger, diagnostics }) {
4337
+ if (logger.jsonMode) return;
4338
+ for (const diagnostic of diagnostics) {
4339
+ const formatted = formatDiagnostic(diagnostic);
4340
+ if (diagnostic.severity === "error") logger.error(formatted);
4341
+ else if (diagnostic.severity === "warning") logger.warn(formatted);
4342
+ else logger.info(formatted);
4343
+ }
4344
+ }
4345
+ /**
4346
+ * `rulesync doctor` — read-only diagnostics for the configuration files.
4347
+ * Never writes; exits non-zero when errors (or, with --strict, warnings) are
4348
+ * found.
4349
+ */
4350
+ async function doctorCommand(logger, options) {
4351
+ const cwd = process.cwd();
4352
+ const validatedConfigPath = require_import.resolvePath(options.config ?? "rulesync.jsonc", cwd);
4353
+ const localConfigPath = (0, node_path.join)((0, node_path.dirname)(validatedConfigPath), require_import.RULESYNC_LOCAL_CONFIG_RELATIVE_FILE_PATH);
4354
+ const toDisplayPath = (absolutePath) => {
4355
+ const relativePath = (0, node_path.relative)(cwd, absolutePath);
4356
+ return relativePath === "" || relativePath.startsWith("..") ? absolutePath : relativePath;
4357
+ };
4358
+ const diagnostics = [];
4359
+ if (!await require_import.fileExists(validatedConfigPath)) diagnostics.push({
4360
+ severity: "info",
4361
+ code: "config/no-config-file",
4362
+ file: toDisplayPath(validatedConfigPath),
4363
+ message: "No configuration file found; rulesync will run with built-in defaults.",
4364
+ hint: "Run 'rulesync init' to scaffold one."
4365
+ });
4366
+ const fileContents = /* @__PURE__ */ new Map();
4367
+ for (const filePath of [validatedConfigPath, localConfigPath]) {
4368
+ if (!await require_import.fileExists(filePath)) continue;
4369
+ const content = await require_import.readFileContent(filePath);
4370
+ fileContents.set(filePath, content);
4371
+ diagnostics.push(...collectConfigFileDiagnostics({
4372
+ file: toDisplayPath(filePath),
4373
+ content
4374
+ }));
4375
+ }
4376
+ const baseConfig = parseConfigObjectForMerge(fileContents.get(validatedConfigPath));
4377
+ const localConfig = parseConfigObjectForMerge(fileContents.get(localConfigPath));
4378
+ diagnostics.push(...collectMergedConfigDiagnostics({
4379
+ baseConfig,
4380
+ localConfig,
4381
+ baseFile: toDisplayPath(validatedConfigPath),
4382
+ localFile: toDisplayPath(localConfigPath)
4383
+ }));
4384
+ diagnostics.push(...await checkInputRootExists({
4385
+ baseConfig,
4386
+ localConfig,
4387
+ baseFile: toDisplayPath(validatedConfigPath),
4388
+ localFile: toDisplayPath(localConfigPath)
4389
+ }));
4390
+ diagnostics.sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
4391
+ const errorCount = diagnostics.filter((d) => d.severity === "error").length;
4392
+ const warningCount = diagnostics.filter((d) => d.severity === "warning").length;
4393
+ const infoCount = diagnostics.filter((d) => d.severity === "info").length;
4394
+ reportDiagnostics({
4395
+ logger,
4396
+ diagnostics
4397
+ });
4398
+ if (logger.jsonMode) {
4399
+ logger.captureData("diagnostics", diagnostics);
4400
+ logger.captureData("summary", {
4401
+ errors: errorCount,
4402
+ warnings: warningCount,
4403
+ infos: infoCount
4404
+ });
4405
+ }
4406
+ const summary = `${errorCount} error(s), ${warningCount} warning(s), ${infoCount} info(s)`;
4407
+ if (errorCount > 0 || options.strict === true && warningCount > 0) throw new require_import.CLIError(`Doctor found problems: ${summary}.`, require_import.ErrorCodes.DOCTOR_FAILED, 1, {
4408
+ diagnostics,
4409
+ summary: {
4410
+ errors: errorCount,
4411
+ warnings: warningCount,
4412
+ infos: infoCount
4413
+ }
4414
+ });
4415
+ if (warningCount > 0) {
4416
+ logger.warn(`Doctor finished with ${summary}.`);
4417
+ return;
4418
+ }
4419
+ logger.success(`✓ No problems found (${summary}).`);
4420
+ }
4421
+ //#endregion
3864
4422
  //#region src/lib/fetch.ts
3865
4423
  /**
3866
4424
  * Feature to path mapping for filtering (rulesync format)
@@ -9297,7 +9855,7 @@ function wrapCommand$1({ name, errorCode, handler, getVersion, loggerFactory = c
9297
9855
  }
9298
9856
  //#endregion
9299
9857
  //#region src/cli/program.ts
9300
- const getVersion = () => "16.2.0";
9858
+ const getVersion = () => "16.3.0";
9301
9859
  const FEATURES_HELP = `${require_import.ALL_FEATURES.join(",")}; ignore is deprecated, use permissions`;
9302
9860
  function wrapCommand(name, errorCode, handler) {
9303
9861
  return wrapCommand$1({
@@ -9369,6 +9927,9 @@ function createProgram() {
9369
9927
  program.command("generate").description("Generate configuration files for AI tools").option("-t, --targets <tools>", "Comma-separated list of tools to generate for (e.g., 'copilot,cursor,cline' or '*' for all)", parseCommaSeparatedList).option("-f, --features <features>", `Comma-separated list of features to generate (${FEATURES_HELP}) or '*' for all`, parseCommaSeparatedList).option("--delete", "Delete all existing files in output directories before generating").option("-o, --output-roots <paths>", "Output root directories to generate files into (comma-separated for multiple paths)", parseCommaSeparatedList).option("-V, --verbose", "Verbose output").option("-s, --silent", "Suppress all output").option("-c, --config <path>", "Path to configuration file").option("-g, --global", "Generate for global(user scope) configuration files").option("--simulate-commands", "Generate simulated commands. This feature is only available for copilot, cursor and codexcli.").option("--simulate-subagents", "Generate simulated subagents. This feature is only available for copilot and codexcli.").option("--simulate-skills", "Generate simulated skills. This feature is only available for copilot, cursor and codexcli.").option("--input-root <path>", "Path to the directory containing .rulesync/ (parent of .rulesync/)").option("--dry-run", "Dry run: show changes without writing files").option("--check", "Check if files are up to date (exits with code 1 if changes needed)").option("-w, --watch", "Keep running and regenerate whenever rulesync source files change (cannot be combined with --check, --dry-run or --json)").action(wrapCommand("generate", "GENERATION_FAILED", async (logger, options) => {
9370
9928
  await generateCommand(logger, options);
9371
9929
  }));
9930
+ program.command("doctor").description("Diagnose the rulesync configuration for common problems (read-only, never writes files)").option("-c, --config <path>", "Path to configuration file").option("--strict", "Treat warnings as errors (exit with code 1)").option("-V, --verbose", "Verbose output").option("-s, --silent", "Suppress all output").action(wrapCommand("doctor", "DOCTOR_FAILED", async (logger, options) => {
9931
+ await doctorCommand(logger, options);
9932
+ }));
9372
9933
  program.command("update").description("Update rulesync to the latest version").option("--check", "Check for updates without installing").option("--force", "Force update even if already at latest version").option("--token <token>", "GitHub token for API access").option("-V, --verbose", "Verbose output").option("-s, --silent", "Suppress all output").action(wrapCommand("update", "UPDATE_FAILED", async (logger, options) => {
9373
9934
  await updateCommand(logger, version, options);
9374
9935
  }));