@vibe-agent-toolkit/resources 0.1.13 → 0.1.14

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.
@@ -11,7 +11,7 @@
11
11
  import type fs from 'node:fs/promises';
12
12
  import path from 'node:path';
13
13
 
14
- import { crawlDirectory, type CrawlOptions as UtilsCrawlOptions, type GitTracker, normalizedTmpdir } from '@vibe-agent-toolkit/utils';
14
+ import { crawlDirectory, type CrawlOptions as UtilsCrawlOptions, type GitTracker, normalizedTmpdir, toForwardSlash } from '@vibe-agent-toolkit/utils';
15
15
 
16
16
  import { calculateChecksum } from './checksum.js';
17
17
  import { getCollectionsForFile } from './collection-matcher.js';
@@ -44,8 +44,10 @@ export interface CrawlOptions {
44
44
  * Options for ResourceRegistry constructor.
45
45
  */
46
46
  export interface ResourceRegistryOptions {
47
- /** Root directory for resources (optional) */
48
- rootDir?: string;
47
+ /** Base directory for resources. Used for relative-path ID generation and schema resolution. */
48
+ baseDir?: string;
49
+ /** Frontmatter field name to use as resource ID (optional). When set, the value of this frontmatter field takes priority over path-based ID generation. */
50
+ idField?: string;
49
51
  /** Project configuration (optional, enables collection support) */
50
52
  config?: ProjectConfig;
51
53
  /** Git tracker for efficient git-ignore checking (optional, improves performance) */
@@ -131,8 +133,11 @@ export interface CollectionStats {
131
133
  * ```
132
134
  */
133
135
  export class ResourceRegistry implements ResourceCollectionInterface {
134
- /** Optional root directory for resources */
135
- readonly rootDir?: string;
136
+ /** Base directory for resources. Used for relative-path ID generation and schema resolution. Set via constructor or propagated from crawl(). */
137
+ baseDir?: string;
138
+
139
+ /** Frontmatter field name to use as resource ID. */
140
+ readonly idField?: string;
136
141
 
137
142
  /** Optional project configuration (enables collection support) */
138
143
  readonly config?: ProjectConfig;
@@ -146,8 +151,11 @@ export class ResourceRegistry implements ResourceCollectionInterface {
146
151
  private readonly resourcesByChecksum: Map<SHA256, ResourceMetadata[]> = new Map();
147
152
 
148
153
  constructor(options?: ResourceRegistryOptions) {
149
- if (options?.rootDir !== undefined) {
150
- this.rootDir = options.rootDir;
154
+ if (options?.baseDir !== undefined) {
155
+ this.baseDir = options.baseDir;
156
+ }
157
+ if (options?.idField !== undefined) {
158
+ this.idField = options.idField;
151
159
  }
152
160
  if (options?.config !== undefined) {
153
161
  this.config = options.config;
@@ -158,32 +166,34 @@ export class ResourceRegistry implements ResourceCollectionInterface {
158
166
  }
159
167
 
160
168
  /**
161
- * Create an empty registry with a root directory.
169
+ * Create an empty registry with a base directory.
162
170
  *
163
- * @param rootDir - Root directory for resources
171
+ * @param baseDir - Base directory for resources
164
172
  * @param options - Additional options
165
173
  * @returns New empty registry
166
174
  *
167
175
  * @example
168
176
  * ```typescript
169
177
  * const registry = ResourceRegistry.empty('/project/docs');
170
- * console.log(registry.rootDir); // '/project/docs'
178
+ * console.log(registry.baseDir); // '/project/docs'
171
179
  * console.log(registry.size()); // 0
172
180
  * ```
173
181
  */
174
- static empty(rootDir: string, options?: Omit<ResourceRegistryOptions, 'rootDir'>): ResourceRegistry {
175
- return new ResourceRegistry({ ...options, rootDir });
182
+ static empty(baseDir: string, options?: Omit<ResourceRegistryOptions, 'baseDir'>): ResourceRegistry {
183
+ return new ResourceRegistry({ ...options, baseDir });
176
184
  }
177
185
 
178
186
  /**
179
187
  * Create a registry from an existing array of resources.
180
188
  *
181
189
  * Initializes all indexes (by path, ID, name, checksum) from the provided resources.
190
+ * Throws if any resources have duplicate IDs.
182
191
  *
183
- * @param rootDir - Root directory for resources
192
+ * @param baseDir - Base directory for resources
184
193
  * @param resources - Array of resource metadata
185
194
  * @param options - Additional options
186
195
  * @returns New registry with resources
196
+ * @throws Error if duplicate resource IDs are found
187
197
  *
188
198
  * @example
189
199
  * ```typescript
@@ -193,14 +203,22 @@ export class ResourceRegistry implements ResourceCollectionInterface {
193
203
  * ```
194
204
  */
195
205
  static fromResources(
196
- rootDir: string,
206
+ baseDir: string,
197
207
  resources: ResourceMetadata[],
198
- options?: Omit<ResourceRegistryOptions, 'rootDir'>,
208
+ options?: Omit<ResourceRegistryOptions, 'baseDir'>,
199
209
  ): ResourceRegistry {
200
- const registry = new ResourceRegistry({ ...options, rootDir });
210
+ const registry = new ResourceRegistry({ ...options, baseDir });
201
211
 
202
212
  // Add all resources to indexes
203
213
  for (const resource of resources) {
214
+ // Check for duplicate ID
215
+ const existingById = registry.resourcesById.get(resource.id);
216
+ if (existingById) {
217
+ throw new Error(
218
+ `Duplicate resource ID '${resource.id}': '${resource.filePath}' conflicts with '${existingById.filePath}'`
219
+ );
220
+ }
221
+
204
222
  // Add to path index
205
223
  registry.resourcesByPath.set(resource.filePath, resource);
206
224
 
@@ -241,9 +259,9 @@ export class ResourceRegistry implements ResourceCollectionInterface {
241
259
  */
242
260
  static async fromCrawl(
243
261
  crawlOptions: CrawlOptions,
244
- registryOptions?: Omit<ResourceRegistryOptions, 'rootDir'>,
262
+ registryOptions?: Omit<ResourceRegistryOptions, 'baseDir'>,
245
263
  ): Promise<ResourceRegistry> {
246
- const registry = new ResourceRegistry({ ...registryOptions, rootDir: crawlOptions.baseDir });
264
+ const registry = new ResourceRegistry({ ...registryOptions, baseDir: crawlOptions.baseDir });
247
265
  await registry.crawl(crawlOptions);
248
266
  return registry;
249
267
  }
@@ -267,11 +285,19 @@ export class ResourceRegistry implements ResourceCollectionInterface {
267
285
  // Normalize path to absolute
268
286
  const absolutePath = path.resolve(filePath);
269
287
 
270
- // Parse the markdown file
288
+ // Parse the markdown file (needed before ID generation for frontmatter lookup)
271
289
  const parseResult = await parseMarkdown(absolutePath);
272
290
 
273
- // Generate unique ID from file path
274
- const id = this.generateUniqueId(absolutePath);
291
+ // Generate ID using priority chain: frontmatter field → relative path → filename stem
292
+ const id = this.generateId(absolutePath, parseResult.frontmatter);
293
+
294
+ // Check for duplicate ID (allow re-adding same file path)
295
+ const existingById = this.resourcesById.get(id);
296
+ if (existingById && existingById.filePath !== absolutePath) {
297
+ throw new Error(
298
+ `Duplicate resource ID '${id}': '${absolutePath}' conflicts with '${existingById.filePath}'`
299
+ );
300
+ }
275
301
 
276
302
  // Get file modified time
277
303
  const fs = await import('node:fs/promises');
@@ -307,10 +333,13 @@ export class ResourceRegistry implements ResourceCollectionInterface {
307
333
  }
308
334
 
309
335
  /**
310
- * Add multiple resources to the registry in parallel.
336
+ * Add multiple resources to the registry sequentially.
337
+ *
338
+ * Sequential execution ensures deterministic duplicate ID detection.
311
339
  *
312
340
  * @param filePaths - Array of file paths to add
313
341
  * @returns Array of parsed resource metadata
342
+ * @throws Error if any resource produces a duplicate ID
314
343
  *
315
344
  * @example
316
345
  * ```typescript
@@ -322,7 +351,11 @@ export class ResourceRegistry implements ResourceCollectionInterface {
322
351
  * ```
323
352
  */
324
353
  async addResources(filePaths: string[]): Promise<ResourceMetadata[]> {
325
- return await Promise.all(filePaths.map((fp) => this.addResource(fp)));
354
+ const results: ResourceMetadata[] = [];
355
+ for (const fp of filePaths) {
356
+ results.push(await this.addResource(fp));
357
+ }
358
+ return results;
326
359
  }
327
360
 
328
361
  /**
@@ -349,6 +382,11 @@ export class ResourceRegistry implements ResourceCollectionInterface {
349
382
  followSymlinks = false,
350
383
  } = options;
351
384
 
385
+ // Propagate baseDir to registry if not already set (enables path-relative IDs)
386
+ if (baseDir && !this.baseDir) {
387
+ this.baseDir = baseDir;
388
+ }
389
+
352
390
  // Use utils file crawler
353
391
  const crawlOptions: UtilsCrawlOptions = {
354
392
  baseDir,
@@ -398,10 +436,10 @@ export class ResourceRegistry implements ResourceCollectionInterface {
398
436
  for (const resource of this.resourcesByPath.values()) {
399
437
  for (const link of resource.links) {
400
438
  // Only pass options if projectRoot is defined (exactOptionalPropertyTypes requirement)
401
- const validateOptions = this.rootDir === undefined
439
+ const validateOptions = this.baseDir === undefined
402
440
  ? { skipGitIgnoreCheck }
403
441
  : {
404
- projectRoot: this.rootDir,
442
+ projectRoot: this.baseDir,
405
443
  skipGitIgnoreCheck,
406
444
  ...(this.gitTracker !== undefined && { gitTracker: this.gitTracker })
407
445
  };
@@ -515,7 +553,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
515
553
  }
516
554
 
517
555
  const schemaPath = path.resolve(
518
- this.rootDir ?? process.cwd(),
556
+ this.baseDir ?? process.cwd(),
519
557
  validation.frontmatterSchema
520
558
  );
521
559
 
@@ -1116,31 +1154,23 @@ export class ResourceRegistry implements ResourceCollectionInterface {
1116
1154
  }
1117
1155
 
1118
1156
  /**
1119
- * Generate a unique ID from a file path.
1120
- *
1121
- * Process:
1122
- * 1. Get basename without extension
1123
- * 2. Convert to kebab-case
1124
- * 3. Handle collisions by appending suffix (-2, -3, etc.)
1157
+ * Generate a resource ID using the priority chain:
1158
+ * 1. Frontmatter field (if `idField` is configured and field exists)
1159
+ * 2. Relative path from `baseDir` (if `baseDir` is set)
1160
+ * 3. Filename stem (fallback)
1125
1161
  *
1126
1162
  * @param filePath - Absolute file path
1127
- * @returns Unique ID
1163
+ * @param frontmatter - Parsed frontmatter (optional)
1164
+ * @returns Resource ID
1128
1165
  */
1129
- private generateUniqueId(filePath: string): string {
1130
- const baseId = generateIdFromPath(filePath);
1131
-
1132
- // Check for collision
1133
- if (!this.resourcesById.has(baseId)) {
1134
- return baseId;
1166
+ private generateId(filePath: string, frontmatter?: Record<string, unknown>): string {
1167
+ // Priority 1: Frontmatter field
1168
+ if (this.idField && frontmatter?.[this.idField] !== undefined) {
1169
+ return String(frontmatter[this.idField]);
1135
1170
  }
1136
1171
 
1137
- // Handle collision by appending suffix
1138
- let suffix = 2;
1139
- while (this.resourcesById.has(`${baseId}-${suffix}`)) {
1140
- suffix++;
1141
- }
1142
-
1143
- return `${baseId}-${suffix}`;
1172
+ // Priority 2/3: Path-based (relative to baseDir, or filename stem)
1173
+ return generateIdFromPath(filePath, this.baseDir);
1144
1174
  }
1145
1175
 
1146
1176
  /**
@@ -1212,32 +1242,45 @@ export class ResourceRegistry implements ResourceCollectionInterface {
1212
1242
  /**
1213
1243
  * Generate an ID from a file path.
1214
1244
  *
1215
- * Process:
1216
- * 1. Remove extension (.md)
1217
- * 2. Get basename
1218
- * 3. Convert to kebab-case
1219
- * 4. Remove non-alphanumeric characters except hyphens
1245
+ * When `baseDir` is provided, computes a relative path from baseDir and uses the full
1246
+ * directory structure in the ID. When no `baseDir`, uses the filename stem only.
1220
1247
  *
1221
- * @param filePath - File path
1222
- * @returns Generated ID (not yet checked for uniqueness)
1248
+ * @param filePath - Absolute file path
1249
+ * @param baseDir - Base directory for relative path computation (optional)
1250
+ * @returns Generated ID in kebab-case
1223
1251
  *
1224
1252
  * @example
1225
1253
  * ```typescript
1254
+ * // Without baseDir: filename stem only
1226
1255
  * generateIdFromPath('/project/docs/User Guide.md') // 'user-guide'
1227
- * generateIdFromPath('/project/README.md') // 'readme'
1228
- * generateIdFromPath('/project/docs/API_v2.md') // 'api-v2'
1256
+ * generateIdFromPath('/project/README.md') // 'readme'
1257
+ *
1258
+ * // With baseDir: relative path
1259
+ * generateIdFromPath('/project/docs/concepts/core/overview.md', '/project/docs') // 'concepts-core-overview'
1260
+ * generateIdFromPath('/project/docs/guide.md', '/project/docs') // 'guide'
1229
1261
  * ```
1230
1262
  */
1231
- function generateIdFromPath(filePath: string): string {
1232
- // Get basename without extension
1233
- const basename = path.basename(filePath, path.extname(filePath));
1263
+ export function generateIdFromPath(filePath: string, baseDir?: string): string {
1264
+ let rawId: string;
1265
+
1266
+ if (baseDir) {
1267
+ // Compute relative path from baseDir, remove extension
1268
+ const relativePath = path.relative(baseDir, filePath);
1269
+ const ext = path.extname(relativePath);
1270
+ const withoutExt = ext ? relativePath.slice(0, -ext.length) : relativePath;
1271
+ // Normalize path separators to forward slashes (cross-platform), then replace with hyphens
1272
+ rawId = toForwardSlash(withoutExt).replaceAll('/', '-');
1273
+ } else {
1274
+ // Fallback: basename only (no directory context)
1275
+ rawId = path.basename(filePath, path.extname(filePath));
1276
+ }
1234
1277
 
1235
1278
  // Convert to kebab-case:
1236
1279
  // 1. Replace underscores and spaces with hyphens
1237
1280
  // 2. Convert to lowercase
1238
1281
  // 3. Remove non-alphanumeric except hyphens
1239
1282
  // 4. Collapse multiple hyphens
1240
- return basename
1283
+ return rawId
1241
1284
  .replaceAll(/[_\s]+/g, '-')
1242
1285
  .toLowerCase()
1243
1286
  .replaceAll(/[^\da-z-]/g, '')