@intellectronica/ruler 0.3.41 → 0.3.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/README.md +135 -36
  2. package/dist/agents/AbstractAgent.d.ts +53 -0
  3. package/dist/agents/AbstractAgent.js +3 -2
  4. package/dist/agents/AgentsMdAgent.d.ts +14 -0
  5. package/dist/agents/AgentsMdAgent.js +3 -2
  6. package/dist/agents/AiderAgent.d.ts +14 -0
  7. package/dist/agents/AiderAgent.js +7 -4
  8. package/dist/agents/AmazonQCliAgent.d.ts +13 -0
  9. package/dist/agents/AmazonQCliAgent.js +6 -4
  10. package/dist/agents/AmpAgent.d.ts +6 -0
  11. package/dist/agents/AntigravityAgent.d.ts +10 -0
  12. package/dist/agents/AugmentCodeAgent.d.ts +13 -0
  13. package/dist/agents/AugmentCodeAgent.js +3 -2
  14. package/dist/agents/ClaudeAgent.d.ts +13 -0
  15. package/dist/agents/ClineAgent.d.ts +9 -0
  16. package/dist/agents/CodexCliAgent.d.ts +31 -0
  17. package/dist/agents/CodexCliAgent.js +1 -1
  18. package/dist/agents/CopilotAgent.d.ts +20 -0
  19. package/dist/agents/CrushAgent.d.ts +14 -0
  20. package/dist/agents/CrushAgent.js +18 -6
  21. package/dist/agents/CursorAgent.d.ts +17 -0
  22. package/dist/agents/FactoryDroidAgent.d.ts +13 -0
  23. package/dist/agents/FirebaseAgent.d.ts +11 -0
  24. package/dist/agents/FirebenderAgent.d.ts +36 -0
  25. package/dist/agents/FirebenderAgent.js +5 -4
  26. package/dist/agents/GeminiCliAgent.d.ts +12 -0
  27. package/dist/agents/GeminiCliAgent.js +13 -7
  28. package/dist/agents/GooseAgent.d.ts +12 -0
  29. package/dist/agents/IAgent.d.ts +74 -0
  30. package/dist/agents/JetBrainsAiAssistantAgent.d.ts +10 -0
  31. package/dist/agents/JulesAgent.d.ts +5 -0
  32. package/dist/agents/JunieAgent.d.ts +12 -0
  33. package/dist/agents/KiloCodeAgent.d.ts +14 -0
  34. package/dist/agents/KiroAgent.d.ts +8 -0
  35. package/dist/agents/MistralVibeAgent.d.ts +31 -0
  36. package/dist/agents/MistralVibeAgent.js +14 -3
  37. package/dist/agents/OpenCodeAgent.d.ts +11 -0
  38. package/dist/agents/OpenCodeAgent.js +24 -12
  39. package/dist/agents/OpenHandsAgent.d.ts +8 -0
  40. package/dist/agents/PiAgent.d.ts +9 -0
  41. package/dist/agents/QwenCodeAgent.d.ts +11 -0
  42. package/dist/agents/QwenCodeAgent.js +11 -5
  43. package/dist/agents/RooCodeAgent.d.ts +16 -0
  44. package/dist/agents/RooCodeAgent.js +3 -2
  45. package/dist/agents/TraeAgent.d.ts +10 -0
  46. package/dist/agents/WarpAgent.d.ts +12 -0
  47. package/dist/agents/WindsurfAgent.d.ts +13 -0
  48. package/dist/agents/ZedAgent.d.ts +21 -0
  49. package/dist/agents/ZedAgent.js +8 -5
  50. package/dist/agents/agent-utils.d.ts +5 -0
  51. package/dist/agents/agent-utils.js +8 -5
  52. package/dist/agents/index.d.ts +9 -0
  53. package/dist/cli/commands.d.ts +4 -0
  54. package/dist/cli/commands.js +1 -2
  55. package/dist/cli/handlers.d.ts +41 -0
  56. package/dist/cli/handlers.js +75 -59
  57. package/dist/cli/index.d.ts +2 -0
  58. package/dist/constants.d.ts +35 -0
  59. package/dist/constants.js +1 -1
  60. package/dist/core/ConfigLoader.d.ts +59 -0
  61. package/dist/core/ConfigLoader.js +178 -44
  62. package/dist/core/FileSystemUtils.d.ts +53 -0
  63. package/dist/core/FileSystemUtils.js +157 -20
  64. package/dist/core/GitignoreUtils.d.ts +25 -0
  65. package/dist/core/GitignoreUtils.js +94 -32
  66. package/dist/core/RuleProcessor.d.ts +8 -0
  67. package/dist/core/SkillsProcessor.d.ts +127 -0
  68. package/dist/core/SkillsProcessor.js +118 -223
  69. package/dist/core/SkillsUtils.d.ts +26 -0
  70. package/dist/core/SubagentsProcessor.d.ts +38 -0
  71. package/dist/core/SubagentsProcessor.js +8 -5
  72. package/dist/core/SubagentsUtils.d.ts +34 -0
  73. package/dist/core/UnifiedConfigLoader.d.ts +10 -0
  74. package/dist/core/UnifiedConfigLoader.js +115 -33
  75. package/dist/core/UnifiedConfigTypes.d.ts +97 -0
  76. package/dist/core/agent-selection.d.ts +12 -0
  77. package/dist/core/agent-selection.js +17 -7
  78. package/dist/core/apply-engine.d.ts +70 -0
  79. package/dist/core/apply-engine.js +88 -58
  80. package/dist/core/config-utils.d.ts +14 -0
  81. package/dist/core/config-utils.js +9 -3
  82. package/dist/core/hash.d.ts +2 -0
  83. package/dist/core/path-utils.d.ts +1 -0
  84. package/dist/core/path-utils.js +42 -0
  85. package/dist/core/revert-engine.d.ts +37 -0
  86. package/dist/core/revert-engine.js +142 -34
  87. package/dist/lib.d.ts +13 -0
  88. package/dist/lib.js +24 -8
  89. package/dist/mcp/capabilities.d.ts +20 -0
  90. package/dist/mcp/merge.d.ts +10 -0
  91. package/dist/mcp/merge.js +36 -16
  92. package/dist/mcp/propagateOpenCodeMcp.d.ts +2 -0
  93. package/dist/mcp/propagateOpenCodeMcp.js +30 -11
  94. package/dist/mcp/propagateOpenHandsMcp.d.ts +2 -0
  95. package/dist/mcp/propagateOpenHandsMcp.js +48 -21
  96. package/dist/mcp/validate.d.ts +7 -0
  97. package/dist/mcp/validate.js +6 -1
  98. package/dist/paths/mcp.d.ts +8 -0
  99. package/dist/paths/mcp.js +44 -8
  100. package/dist/revert.d.ts +6 -0
  101. package/dist/revert.js +58 -46
  102. package/dist/types.d.ts +87 -0
  103. package/dist/vscode/settings.d.ts +40 -0
  104. package/dist/vscode/settings.js +3 -3
  105. package/package.json +8 -5
@@ -40,6 +40,7 @@ const path = __importStar(require("path"));
40
40
  const os = __importStar(require("os"));
41
41
  const toml_1 = require("@iarna/toml");
42
42
  const zod_1 = require("zod");
43
+ const path_utils_1 = require("./path-utils");
43
44
  const constants_1 = require("../constants");
44
45
  // One-shot guard so the deprecation message fires once per process even when
45
46
  // `loadConfig` is called multiple times (e.g. nested mode walks every
@@ -61,6 +62,7 @@ const mcpConfigSchema = zod_1.z
61
62
  enabled: zod_1.z.boolean().optional(),
62
63
  merge_strategy: zod_1.z.enum(['merge', 'overwrite']).optional(),
63
64
  })
65
+ .strict()
64
66
  .optional();
65
67
  const agentConfigSchema = zod_1.z
66
68
  .object({
@@ -69,7 +71,11 @@ const agentConfigSchema = zod_1.z
69
71
  output_path_instructions: zod_1.z.string().optional(),
70
72
  output_path_config: zod_1.z.string().optional(),
71
73
  mcp: mcpConfigSchema,
74
+ mcp_servers: zod_1.z
75
+ .record(zod_1.z.string(), zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()))
76
+ .optional(),
72
77
  })
78
+ .strict()
73
79
  .optional();
74
80
  // `[agents]` is a heterogeneous table that holds two unrelated kinds of keys:
75
81
  // - reserved subagent-control booleans (`enabled`, `include_in_rules`)
@@ -81,7 +87,8 @@ const SUBAGENT_RESERVED_KEYS = new Set([
81
87
  'include_in_rules',
82
88
  'cleanup_orphaned',
83
89
  ]);
84
- const rulerConfigSchema = zod_1.z.object({
90
+ const rulerConfigSchema = zod_1.z
91
+ .object({
85
92
  default_agents: zod_1.z.array(zod_1.z.string()).optional(),
86
93
  agents: zod_1.z
87
94
  .object({
@@ -96,17 +103,29 @@ const rulerConfigSchema = zod_1.z.object({
96
103
  enabled: zod_1.z.boolean().optional(),
97
104
  merge_strategy: zod_1.z.enum(['merge', 'overwrite']).optional(),
98
105
  })
106
+ .strict()
107
+ .optional(),
108
+ mcp_servers: zod_1.z
109
+ .record(zod_1.z.string(), zod_1.z.record(zod_1.z.string(), zod_1.z.unknown()))
99
110
  .optional(),
100
111
  gitignore: zod_1.z
101
112
  .object({
102
113
  enabled: zod_1.z.boolean().optional(),
103
114
  local: zod_1.z.boolean().optional(),
104
115
  })
116
+ .strict()
117
+ .optional(),
118
+ backup: zod_1.z
119
+ .object({
120
+ enabled: zod_1.z.boolean().optional(),
121
+ })
122
+ .strict()
105
123
  .optional(),
106
124
  skills: zod_1.z
107
125
  .object({
108
126
  enabled: zod_1.z.boolean().optional(),
109
127
  })
128
+ .strict()
110
129
  .optional(),
111
130
  // Deprecated: kept in the schema only so that legacy `[subagents]` blocks
112
131
  // are preserved through validation. The parser reads from here as a
@@ -118,9 +137,11 @@ const rulerConfigSchema = zod_1.z.object({
118
137
  include_in_rules: zod_1.z.boolean().optional(),
119
138
  cleanup_orphaned: zod_1.z.boolean().optional(),
120
139
  })
140
+ .strict()
121
141
  .optional(),
122
142
  nested: zod_1.z.boolean().optional(),
123
- });
143
+ })
144
+ .strict();
124
145
  /**
125
146
  * Recursively creates a new object with only enumerable string keys,
126
147
  * effectively excluding Symbol properties.
@@ -142,50 +163,44 @@ function stripSymbols(obj) {
142
163
  }
143
164
  return result;
144
165
  }
166
+ function parseAgentMcpServers(sectionObj) {
167
+ if (!sectionObj.mcp_servers ||
168
+ typeof sectionObj.mcp_servers !== 'object' ||
169
+ Array.isArray(sectionObj.mcp_servers)) {
170
+ return undefined;
171
+ }
172
+ const servers = {};
173
+ for (const [name, def] of Object.entries(sectionObj.mcp_servers)) {
174
+ if (def && typeof def === 'object' && !Array.isArray(def)) {
175
+ servers[name] = normalizeAgentMcpServer(def);
176
+ }
177
+ }
178
+ return Object.keys(servers).length > 0 ? servers : undefined;
179
+ }
180
+ function normalizeAgentMcpServer(def) {
181
+ const server = { ...def };
182
+ const hasCommand = typeof server.command === 'string';
183
+ const hasUrl = typeof server.url === 'string';
184
+ if (hasCommand && hasUrl) {
185
+ delete server.command;
186
+ delete server.args;
187
+ delete server.env;
188
+ server.type = 'remote';
189
+ }
190
+ return server;
191
+ }
145
192
  /**
146
193
  * Loads and parses the ruler TOML configuration file, applying defaults.
147
- * If the file is missing or invalid, returns empty/default config.
194
+ * Missing implicit configs return defaults. Explicit configs and existing
195
+ * implicit configs fail fast when missing, unreadable, or invalid.
148
196
  */
149
197
  async function loadConfig(options) {
150
198
  const { projectRoot, configPath, cliAgents } = options;
151
- let configFile;
152
- if (configPath) {
153
- configFile = path.resolve(configPath);
154
- }
155
- else {
156
- // Try local .ruler/ruler.toml first
157
- const localConfigFile = path.join(projectRoot, '.ruler', 'ruler.toml');
158
- try {
159
- await fs_1.promises.access(localConfigFile);
160
- configFile = localConfigFile;
161
- }
162
- catch {
163
- // If local config doesn't exist, try global config
164
- const xdgConfigDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
165
- configFile = path.join(xdgConfigDir, 'ruler', 'ruler.toml');
166
- }
167
- }
168
- let raw = {};
169
- try {
170
- const text = await fs_1.promises.readFile(configFile, 'utf8');
171
- const parsed = text.trim() ? (0, toml_1.parse)(text) : {};
172
- // Strip Symbol properties added by @iarna/toml (required for Zod v4+)
173
- raw = stripSymbols(parsed);
174
- // Validate the configuration with zod
175
- const validationResult = rulerConfigSchema.safeParse(raw);
176
- if (!validationResult.success) {
177
- throw (0, constants_1.createRulerError)('Invalid configuration file format', `File: ${configFile}, Errors: ${validationResult.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join(', ')}`);
178
- }
179
- }
180
- catch (err) {
181
- if (err instanceof Error && err.code !== 'ENOENT') {
182
- if (err.message.includes('[ruler]')) {
183
- throw err; // Re-throw validation errors
184
- }
185
- console.warn(`[ruler] Warning: could not read config file at ${configFile}: ${err.message}`);
186
- }
187
- raw = {};
188
- }
199
+ const checkGlobal = options.checkGlobal ?? true;
200
+ const configFile = configPath
201
+ ? path.resolve(configPath)
202
+ : await resolveImplicitConfigFile(projectRoot, checkGlobal);
203
+ const raw = configFile ? await readConfigFile(configFile) : {};
189
204
  const defaultAgents = Array.isArray(raw.default_agents)
190
205
  ? raw.default_agents.map((a) => String(a))
191
206
  : undefined;
@@ -206,13 +221,13 @@ async function loadConfig(options) {
206
221
  cfg.enabled = sectionObj.enabled;
207
222
  }
208
223
  if (typeof sectionObj.output_path === 'string') {
209
- cfg.outputPath = path.resolve(projectRoot, sectionObj.output_path);
224
+ cfg.outputPath = resolveProjectOutputPath(projectRoot, sectionObj.output_path, configFile, `[agents.${name}].output_path`);
210
225
  }
211
226
  if (typeof sectionObj.output_path_instructions === 'string') {
212
- cfg.outputPathInstructions = path.resolve(projectRoot, sectionObj.output_path_instructions);
227
+ cfg.outputPathInstructions = resolveProjectOutputPath(projectRoot, sectionObj.output_path_instructions, configFile, `[agents.${name}].output_path_instructions`);
213
228
  }
214
229
  if (typeof sectionObj.output_path_config === 'string') {
215
- cfg.outputPathConfig = path.resolve(projectRoot, sectionObj.output_path_config);
230
+ cfg.outputPathConfig = resolveProjectOutputPath(projectRoot, sectionObj.output_path_config, configFile, `[agents.${name}].output_path_config`);
216
231
  }
217
232
  if (sectionObj.mcp && typeof sectionObj.mcp === 'object') {
218
233
  const m = sectionObj.mcp;
@@ -228,6 +243,7 @@ async function loadConfig(options) {
228
243
  }
229
244
  cfg.mcp = mcpCfg;
230
245
  }
246
+ cfg.mcpServers = parseAgentMcpServers(sectionObj);
231
247
  agentConfigs[name] = cfg;
232
248
  }
233
249
  }
@@ -256,6 +272,13 @@ async function loadConfig(options) {
256
272
  if (typeof rawGitignoreSection.local === 'boolean') {
257
273
  gitignoreConfig.local = rawGitignoreSection.local;
258
274
  }
275
+ const rawBackupSection = raw.backup && typeof raw.backup === 'object' && !Array.isArray(raw.backup)
276
+ ? raw.backup
277
+ : {};
278
+ const backupConfig = {};
279
+ if (typeof rawBackupSection.enabled === 'boolean') {
280
+ backupConfig.enabled = rawBackupSection.enabled;
281
+ }
259
282
  const rawSkillsSection = raw.skills && typeof raw.skills === 'object' && !Array.isArray(raw.skills)
260
283
  ? raw.skills
261
284
  : {};
@@ -314,9 +337,120 @@ async function loadConfig(options) {
314
337
  cliAgents,
315
338
  mcp: globalMcpConfig,
316
339
  gitignore: gitignoreConfig,
340
+ backup: backupConfig,
317
341
  skills: skillsConfig,
318
342
  subagents: subagentsConfig,
319
343
  nested,
320
344
  nestedDefined,
321
345
  };
322
346
  }
347
+ function resolveProjectOutputPath(projectRoot, configuredPath, configFile, fieldName) {
348
+ const resolvedPath = path.resolve(projectRoot, configuredPath);
349
+ if (!(0, path_utils_1.isPathInsideOrEqual)(projectRoot, resolvedPath)) {
350
+ throw (0, constants_1.createRulerError)('Configured output path is outside the project root', [
351
+ configFile ? `File: ${configFile}` : undefined,
352
+ `Field: ${fieldName}`,
353
+ `Path: ${configuredPath}`,
354
+ `Project root: ${projectRoot}`,
355
+ ]
356
+ .filter(Boolean)
357
+ .join(', '));
358
+ }
359
+ return resolvedPath;
360
+ }
361
+ async function resolveImplicitConfigFile(projectRoot, checkGlobal) {
362
+ const localRulerDir = await findNearestLocalRulerDir(projectRoot);
363
+ const localConfigFile = localRulerDir
364
+ ? path.join(localRulerDir, 'ruler.toml')
365
+ : path.join(projectRoot, '.ruler', 'ruler.toml');
366
+ if (await configFileExists(localConfigFile)) {
367
+ return localConfigFile;
368
+ }
369
+ if (!checkGlobal) {
370
+ return undefined;
371
+ }
372
+ const xdgConfigDir = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
373
+ const globalConfigFile = path.join(xdgConfigDir, 'ruler', 'ruler.toml');
374
+ if (await configFileExists(globalConfigFile)) {
375
+ return globalConfigFile;
376
+ }
377
+ return undefined;
378
+ }
379
+ async function findNearestLocalRulerDir(startPath) {
380
+ let current = path.resolve(startPath);
381
+ while (current) {
382
+ const candidate = path.join(current, '.ruler');
383
+ try {
384
+ const stat = await fs_1.promises.stat(candidate);
385
+ if (stat.isDirectory()) {
386
+ return candidate;
387
+ }
388
+ }
389
+ catch {
390
+ // Keep walking; missing or inaccessible candidates simply do not match.
391
+ }
392
+ const parent = path.dirname(current);
393
+ if (parent === current) {
394
+ return undefined;
395
+ }
396
+ current = parent;
397
+ }
398
+ return undefined;
399
+ }
400
+ async function configFileExists(configFile) {
401
+ try {
402
+ await fs_1.promises.access(configFile);
403
+ return true;
404
+ }
405
+ catch (err) {
406
+ if (err.code === 'ENOENT') {
407
+ return false;
408
+ }
409
+ throw (0, constants_1.createRulerError)('Could not access configuration file', `File: ${configFile}, Error: ${errorMessage(err)}`);
410
+ }
411
+ }
412
+ async function readConfigFile(configFile) {
413
+ const text = await readConfigText(configFile);
414
+ const parsed = parseConfigText(text, configFile);
415
+ const raw = stripSymbols(parsed);
416
+ validateConfig(raw, configFile);
417
+ return raw;
418
+ }
419
+ async function readConfigText(configFile) {
420
+ try {
421
+ return await fs_1.promises.readFile(configFile, 'utf8');
422
+ }
423
+ catch (err) {
424
+ if (err.code === 'ENOENT') {
425
+ throw (0, constants_1.createRulerError)('Configuration file not found', `File: ${configFile}`);
426
+ }
427
+ throw (0, constants_1.createRulerError)('Could not read configuration file', `File: ${configFile}, Error: ${errorMessage(err)}`);
428
+ }
429
+ }
430
+ function parseConfigText(text, configFile) {
431
+ try {
432
+ return text.trim() ? (0, toml_1.parse)(text) : {};
433
+ }
434
+ catch (err) {
435
+ throw (0, constants_1.createRulerError)('Invalid configuration file', `File: ${configFile}, Error: ${errorMessage(err)}`);
436
+ }
437
+ }
438
+ function validateConfig(raw, configFile) {
439
+ const validationResult = rulerConfigSchema.safeParse(raw);
440
+ if (!validationResult.success) {
441
+ throw (0, constants_1.createRulerError)('Invalid configuration file format', `File: ${configFile}, Errors: ${validationResult.error.issues.map(formatZodIssue).join(', ')}`);
442
+ }
443
+ }
444
+ function formatZodIssue(issue) {
445
+ const basePath = issue.path.join('.');
446
+ if (issue.code === 'unrecognized_keys') {
447
+ const keys = issue.keys;
448
+ return keys
449
+ .map((key) => (basePath ? `${basePath}.${key}` : key))
450
+ .join(', ');
451
+ }
452
+ return `${basePath}: ${issue.message}`;
453
+ }
454
+ function errorMessage(err) {
455
+ return err instanceof Error ? err.message : String(err);
456
+ }
@@ -0,0 +1,53 @@
1
+ export declare function assertManagedPathInsideRoot(managedPath: string, rootPath: string, action: string): Promise<void>;
2
+ /**
3
+ * Searches upwards from startPath to find a directory named .ruler.
4
+ * If not found locally and checkGlobal is true, checks for global config at XDG_CONFIG_HOME/ruler.
5
+ * Returns the path to the .ruler directory, or null if not found.
6
+ */
7
+ export declare function findRulerDir(startPath: string, checkGlobal?: boolean): Promise<string | null>;
8
+ export declare function resolveProjectRootForRulerDir(requestedProjectRoot: string, rulerDir: string): string;
9
+ /**
10
+ * Options for {@link readMarkdownFiles}.
11
+ */
12
+ export interface ReadMarkdownFilesOptions {
13
+ /**
14
+ * When true, include `.ruler/agents/*.md` in the returned set so they are
15
+ * concatenated into the top-level generated rule files. When false or
16
+ * omitted, `.ruler/agents/` is skipped, mirroring `.ruler/skills/`.
17
+ */
18
+ includeAgents?: boolean;
19
+ }
20
+ /**
21
+ * Recursively reads all Markdown (.md) files in rulerDir, returning their paths and contents.
22
+ * Files are sorted alphabetically by path.
23
+ *
24
+ * `.ruler/skills/` is always skipped (skills are propagated separately).
25
+ * `.ruler/agents/` is skipped unless `options.includeAgents` is `true`.
26
+ */
27
+ export declare function readMarkdownFiles(rulerDir: string, options?: ReadMarkdownFilesOptions): Promise<{
28
+ path: string;
29
+ content: string;
30
+ }[]>;
31
+ /**
32
+ * Writes content to filePath, creating parent directories if necessary.
33
+ */
34
+ export declare function writeGeneratedFile(filePath: string, content: string, containmentRoot?: string): Promise<void>;
35
+ /**
36
+ * Creates a backup of the given filePath by copying it to filePath.bak if it exists.
37
+ * Keeps an existing backup intact so repeated applies preserve the original file.
38
+ */
39
+ export declare function backupFile(filePath: string, containmentRoot?: string): Promise<void>;
40
+ /**
41
+ * Ensures that the given directory exists by creating it recursively.
42
+ */
43
+ export declare function ensureDirExists(dirPath: string): Promise<void>;
44
+ /**
45
+ * Finds the global ruler configuration directory at XDG_CONFIG_HOME/ruler.
46
+ * Returns the path if it exists, null otherwise.
47
+ */
48
+ export declare function findGlobalRulerDir(): Promise<string | null>;
49
+ /**
50
+ * Searches the entire directory tree from startPath to find all .ruler directories.
51
+ * Returns an array of .ruler directory paths from most specific to least specific.
52
+ */
53
+ export declare function findAllRulerDirs(startPath: string): Promise<string[]>;
@@ -33,7 +33,9 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.assertManagedPathInsideRoot = assertManagedPathInsideRoot;
36
37
  exports.findRulerDir = findRulerDir;
38
+ exports.resolveProjectRootForRulerDir = resolveProjectRootForRulerDir;
37
39
  exports.readMarkdownFiles = readMarkdownFiles;
38
40
  exports.writeGeneratedFile = writeGeneratedFile;
39
41
  exports.backupFile = backupFile;
@@ -44,13 +46,90 @@ const fs_1 = require("fs");
44
46
  const path = __importStar(require("path"));
45
47
  const os = __importStar(require("os"));
46
48
  const constants_1 = require("../constants");
49
+ const path_utils_1 = require("./path-utils");
47
50
  const SUBAGENTS_DIR_NAME = path.basename(constants_1.RULER_SUBAGENTS_PATH);
51
+ const RULER_GENERATED_MARKER = '<!-- Generated by Ruler -->';
52
+ const DEFAULT_NESTED_DISCOVERY_IGNORES = new Set([
53
+ '__fixtures__',
54
+ '__generated__',
55
+ 'build',
56
+ 'coverage',
57
+ 'dist',
58
+ 'fixtures',
59
+ 'generated',
60
+ 'node_modules',
61
+ 'temp',
62
+ 'tmp',
63
+ ]);
48
64
  /**
49
65
  * Gets the XDG config directory path, falling back to ~/.config if XDG_CONFIG_HOME is not set.
50
66
  */
51
67
  function getXdgConfigDir() {
52
68
  return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
53
69
  }
70
+ function shouldSkipNestedDiscoveryDir(dirName) {
71
+ return (dirName.startsWith('.') || DEFAULT_NESTED_DISCOVERY_IGNORES.has(dirName));
72
+ }
73
+ async function isSymbolicLink(filePath) {
74
+ try {
75
+ return (await fs_1.promises.lstat(filePath)).isSymbolicLink();
76
+ }
77
+ catch {
78
+ return false;
79
+ }
80
+ }
81
+ async function assertNotSymbolicLink(filePath, action) {
82
+ if (await isSymbolicLink(filePath)) {
83
+ throw new Error(`${action}: ${filePath}`);
84
+ }
85
+ }
86
+ async function assertContainingDirectoryInsideRoot(filePath, rootPath, action) {
87
+ const realRoot = await fs_1.promises.realpath(rootPath);
88
+ let current = path.dirname(path.resolve(filePath));
89
+ while (true) {
90
+ try {
91
+ const realCurrent = await fs_1.promises.realpath(current);
92
+ if (!(0, path_utils_1.isPathInsideOrEqual)(realRoot, realCurrent)) {
93
+ throw new Error(`${action}: ${filePath}`);
94
+ }
95
+ return;
96
+ }
97
+ catch (error) {
98
+ if (error.code !== 'ENOENT') {
99
+ throw error;
100
+ }
101
+ }
102
+ const parent = path.dirname(current);
103
+ if (parent === current) {
104
+ return;
105
+ }
106
+ current = parent;
107
+ }
108
+ }
109
+ async function assertManagedPathInsideRoot(managedPath, rootPath, action) {
110
+ const realRoot = await fs_1.promises.realpath(rootPath);
111
+ await assertContainingDirectoryInsideRoot(managedPath, rootPath, action);
112
+ try {
113
+ const realManagedPath = await fs_1.promises.realpath(managedPath);
114
+ if (!(0, path_utils_1.isPathInsideOrEqual)(realRoot, realManagedPath)) {
115
+ throw new Error(`${action}: ${managedPath}`);
116
+ }
117
+ }
118
+ catch (error) {
119
+ if (error.code !== 'ENOENT') {
120
+ throw error;
121
+ }
122
+ }
123
+ }
124
+ async function isRulerGeneratedFile(filePath) {
125
+ try {
126
+ const content = await fs_1.promises.readFile(filePath, 'utf8');
127
+ return content.startsWith(RULER_GENERATED_MARKER);
128
+ }
129
+ catch {
130
+ return false;
131
+ }
132
+ }
54
133
  /**
55
134
  * Searches upwards from startPath to find a directory named .ruler.
56
135
  * If not found locally and checkGlobal is true, checks for global config at XDG_CONFIG_HOME/ruler.
@@ -86,11 +165,19 @@ async function findRulerDir(startPath, checkGlobal = true) {
86
165
  }
87
166
  }
88
167
  catch (err) {
168
+ if (err.code === 'ENOENT') {
169
+ return null;
170
+ }
89
171
  console.error(`[ruler] Error checking global config directory ${globalConfigDir}:`, err);
90
172
  }
91
173
  }
92
174
  return null;
93
175
  }
176
+ function resolveProjectRootForRulerDir(requestedProjectRoot, rulerDir) {
177
+ return path.basename(rulerDir) === '.ruler'
178
+ ? path.dirname(rulerDir)
179
+ : requestedProjectRoot;
180
+ }
94
181
  /**
95
182
  * Recursively reads all Markdown (.md) files in rulerDir, returning their paths and contents.
96
183
  * Files are sorted alphabetically by path.
@@ -101,6 +188,8 @@ async function findRulerDir(startPath, checkGlobal = true) {
101
188
  async function readMarkdownFiles(rulerDir, options = {}) {
102
189
  const mdFiles = [];
103
190
  const includeAgents = options.includeAgents === true;
191
+ const realRulerDir = await fs_1.promises.realpath(rulerDir);
192
+ const visitedDirectories = new Set();
104
193
  // Tracks whether we skipped a `.ruler/agents` subtree so the root-AGENTS.md
105
194
  // fallback below still recognises ruler content as present and does not
106
195
  // resurrect a previously generated root AGENTS.md (which may itself contain
@@ -108,6 +197,17 @@ async function readMarkdownFiles(rulerDir, options = {}) {
108
197
  let sawExcludedAgents = false;
109
198
  // Gather all markdown files (recursive) first
110
199
  async function walk(dir) {
200
+ let realDir;
201
+ try {
202
+ realDir = await fs_1.promises.realpath(dir);
203
+ }
204
+ catch {
205
+ return;
206
+ }
207
+ if (visitedDirectories.has(realDir)) {
208
+ return;
209
+ }
210
+ visitedDirectories.add(realDir);
111
211
  const entries = await fs_1.promises.readdir(dir, { withFileTypes: true });
112
212
  for (const entry of entries) {
113
213
  const fullPath = path.join(dir, entry.name);
@@ -116,6 +216,10 @@ async function readMarkdownFiles(rulerDir, options = {}) {
116
216
  let isFile = entry.isFile();
117
217
  if (entry.isSymbolicLink()) {
118
218
  try {
219
+ const realTarget = await fs_1.promises.realpath(fullPath);
220
+ if (!(0, path_utils_1.isPathInsideOrEqual)(realRulerDir, realTarget)) {
221
+ continue;
222
+ }
119
223
  const stat = await fs_1.promises.stat(fullPath);
120
224
  isDir = stat.isDirectory();
121
225
  isFile = stat.isFile();
@@ -186,7 +290,19 @@ async function readMarkdownFiles(rulerDir, options = {}) {
186
290
  const repoRoot = path.dirname(rulerDir); // .ruler parent
187
291
  const rootAgentsPath = path.join(repoRoot, 'AGENTS.md');
188
292
  if (path.resolve(rootAgentsPath) !== path.resolve(topLevelAgents)) {
189
- const stat = await fs_1.promises.stat(rootAgentsPath);
293
+ const rootAgentsStat = await fs_1.promises.lstat(rootAgentsPath);
294
+ if (rootAgentsStat.isSymbolicLink()) {
295
+ const [realRepoRoot, realRootAgentsPath] = await Promise.all([
296
+ fs_1.promises.realpath(repoRoot),
297
+ fs_1.promises.realpath(rootAgentsPath),
298
+ ]);
299
+ if (!(0, path_utils_1.isPathInsideOrEqual)(realRepoRoot, realRootAgentsPath)) {
300
+ return ordered;
301
+ }
302
+ }
303
+ const stat = rootAgentsStat.isSymbolicLink()
304
+ ? await fs_1.promises.stat(rootAgentsPath)
305
+ : rootAgentsStat;
190
306
  if (stat.isFile()) {
191
307
  const content = await fs_1.promises.readFile(rootAgentsPath, 'utf8');
192
308
  // Check if this is a generated file and we have other .ruler files.
@@ -216,17 +332,41 @@ async function readMarkdownFiles(rulerDir, options = {}) {
216
332
  /**
217
333
  * Writes content to filePath, creating parent directories if necessary.
218
334
  */
219
- async function writeGeneratedFile(filePath, content) {
335
+ async function writeGeneratedFile(filePath, content, containmentRoot) {
336
+ if (containmentRoot) {
337
+ await assertContainingDirectoryInsideRoot(filePath, containmentRoot, 'Refusing to write generated file through symlinked directory');
338
+ }
220
339
  await fs_1.promises.mkdir(path.dirname(filePath), { recursive: true });
340
+ await assertNotSymbolicLink(filePath, 'Refusing to write generated file through symlink');
341
+ if (containmentRoot) {
342
+ await assertContainingDirectoryInsideRoot(filePath, containmentRoot, 'Refusing to write generated file through symlinked directory');
343
+ }
221
344
  await fs_1.promises.writeFile(filePath, content, 'utf8');
222
345
  }
223
346
  /**
224
347
  * Creates a backup of the given filePath by copying it to filePath.bak if it exists.
348
+ * Keeps an existing backup intact so repeated applies preserve the original file.
225
349
  */
226
- async function backupFile(filePath) {
350
+ async function backupFile(filePath, containmentRoot) {
351
+ const backupPath = `${filePath}.bak`;
352
+ if (containmentRoot) {
353
+ await assertManagedPathInsideRoot(filePath, containmentRoot, 'Refusing to back up generated file through symlinked directory');
354
+ await assertManagedPathInsideRoot(backupPath, containmentRoot, 'Refusing to create backup file through symlinked directory');
355
+ }
356
+ await assertNotSymbolicLink(filePath, 'Refusing to back up symlinked file');
357
+ await assertNotSymbolicLink(backupPath, 'Refusing to use symlinked backup file');
358
+ try {
359
+ await fs_1.promises.access(backupPath);
360
+ return;
361
+ }
362
+ catch {
363
+ // continue if no backup exists yet
364
+ }
365
+ if (await isRulerGeneratedFile(filePath)) {
366
+ return;
367
+ }
227
368
  try {
228
- await fs_1.promises.access(filePath);
229
- await fs_1.promises.copyFile(filePath, `${filePath}.bak`);
369
+ await fs_1.promises.copyFile(filePath, backupPath);
230
370
  }
231
371
  catch {
232
372
  // ignore if file does not exist
@@ -272,23 +412,20 @@ async function findAllRulerDirs(startPath) {
272
412
  if (entry.name === '.ruler') {
273
413
  rulerDirs.push(fullPath);
274
414
  }
275
- else {
276
- // Recursively search subdirectories (but skip hidden directories like .git)
277
- if (!entry.name.startsWith('.')) {
278
- // Do not cross git repository boundaries (except the starting root)
279
- const gitDir = path.join(fullPath, '.git');
280
- try {
281
- const gitStat = await fs_1.promises.stat(gitDir);
282
- if (gitStat.isDirectory() &&
283
- path.resolve(fullPath) !== rootPath) {
284
- continue;
285
- }
286
- }
287
- catch {
288
- // no .git boundary, continue traversal
415
+ else if (!shouldSkipNestedDiscoveryDir(entry.name)) {
416
+ // Do not cross git repository boundaries (except the starting root)
417
+ const gitDir = path.join(fullPath, '.git');
418
+ try {
419
+ const gitStat = await fs_1.promises.stat(gitDir);
420
+ if (gitStat.isDirectory() &&
421
+ path.resolve(fullPath) !== rootPath) {
422
+ continue;
289
423
  }
290
- await findRulerDirs(fullPath);
291
424
  }
425
+ catch {
426
+ // no .git boundary, continue traversal
427
+ }
428
+ await findRulerDirs(fullPath);
292
429
  }
293
430
  }
294
431
  }
@@ -0,0 +1,25 @@
1
+ export interface RulerBlockRange {
2
+ start: number;
3
+ end: number;
4
+ }
5
+ export interface RemoveRulerBlocksResult {
6
+ content: string;
7
+ removed: boolean;
8
+ }
9
+ /**
10
+ * Updates an ignore file in the project root with paths in a managed Ruler block.
11
+ * Creates the file if it doesn't exist, and creates or updates the Ruler-managed block.
12
+ *
13
+ * @param projectRoot The project root directory
14
+ * @param paths Array of file paths to add to the ignore file (can be absolute or relative)
15
+ * @param ignoreFile Relative path to the ignore file from project root (defaults to .gitignore)
16
+ */
17
+ export declare function updateGitignore(projectRoot: string, paths: string[], ignoreFile?: string): Promise<void>;
18
+ /**
19
+ * Resolves ignore files Ruler manages. Linked worktrees store `.git` as a
20
+ * file containing a `gitdir:` pointer, so `.git/info/exclude` must be resolved
21
+ * through that pointer.
22
+ */
23
+ export declare function resolveIgnoreFilePath(projectRoot: string, ignoreFile: string): Promise<string>;
24
+ export declare function findCompleteRulerBlocks(lines: string[]): RulerBlockRange[];
25
+ export declare function removeCompleteRulerBlocks(content: string): RemoveRulerBlocksResult;