@vibe-agent-toolkit/resources 0.1.39-rc.1 → 0.1.39-rc.2

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 (51) hide show
  1. package/dist/frontmatter-link-validator.d.ts +4 -4
  2. package/dist/frontmatter-link-validator.d.ts.map +1 -1
  3. package/dist/frontmatter-link-validator.js +3 -3
  4. package/dist/frontmatter-link-validator.js.map +1 -1
  5. package/dist/html-link-parser.d.ts +47 -0
  6. package/dist/html-link-parser.d.ts.map +1 -0
  7. package/dist/html-link-parser.js +111 -0
  8. package/dist/html-link-parser.js.map +1 -0
  9. package/dist/html-transform.d.ts +45 -0
  10. package/dist/html-transform.d.ts.map +1 -0
  11. package/dist/html-transform.js +116 -0
  12. package/dist/html-transform.js.map +1 -0
  13. package/dist/index.d.ts +4 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +3 -1
  16. package/dist/index.js.map +1 -1
  17. package/dist/link-parser.d.ts +26 -2
  18. package/dist/link-parser.d.ts.map +1 -1
  19. package/dist/link-parser.js +28 -1
  20. package/dist/link-parser.js.map +1 -1
  21. package/dist/link-validator.d.ts +50 -3
  22. package/dist/link-validator.d.ts.map +1 -1
  23. package/dist/link-validator.js +72 -55
  24. package/dist/link-validator.js.map +1 -1
  25. package/dist/resource-registry.d.ts +46 -11
  26. package/dist/resource-registry.d.ts.map +1 -1
  27. package/dist/resource-registry.js +140 -34
  28. package/dist/resource-registry.js.map +1 -1
  29. package/dist/schemas/link-auth.d.ts +382 -0
  30. package/dist/schemas/link-auth.d.ts.map +1 -0
  31. package/dist/schemas/link-auth.js +124 -0
  32. package/dist/schemas/link-auth.js.map +1 -0
  33. package/dist/schemas/project-config.d.ts +642 -98
  34. package/dist/schemas/project-config.d.ts.map +1 -1
  35. package/dist/schemas/project-config.js +3 -0
  36. package/dist/schemas/project-config.js.map +1 -1
  37. package/dist/schemas/resource-metadata.d.ts +46 -10
  38. package/dist/schemas/resource-metadata.d.ts.map +1 -1
  39. package/dist/schemas/resource-metadata.js +14 -1
  40. package/dist/schemas/resource-metadata.js.map +1 -1
  41. package/package.json +4 -3
  42. package/src/frontmatter-link-validator.ts +5 -5
  43. package/src/html-link-parser.ts +140 -0
  44. package/src/html-transform.ts +157 -0
  45. package/src/index.ts +6 -1
  46. package/src/link-parser.ts +35 -2
  47. package/src/link-validator.ts +102 -82
  48. package/src/resource-registry.ts +157 -39
  49. package/src/schemas/link-auth.ts +146 -0
  50. package/src/schemas/project-config.ts +4 -0
  51. package/src/schemas/resource-metadata.ts +17 -1
@@ -22,8 +22,9 @@ import {
22
22
  type FrontmatterExternalUrl,
23
23
  } from './frontmatter-link-validator.js';
24
24
  import { validateFrontmatter } from './frontmatter-validator.js';
25
+ import { parseHtml } from './html-link-parser.js';
25
26
  import { parseMarkdown } from './link-parser.js';
26
- import { validateLink, type ValidateLinkOptions } from './link-validator.js';
27
+ import { fragmentIndexEntry, validateLink, type FragmentIndex, type ValidateLinkOptions } from './link-validator.js';
27
28
  import type { ResourceCollectionInterface } from './resource-collection-interface.js';
28
29
  import type { SHA256 } from './schemas/checksum.js';
29
30
  import type { ProjectConfig } from './schemas/project-config.js';
@@ -31,6 +32,27 @@ import type { HeadingNode, ResourceMetadata } from './schemas/resource-metadata.
31
32
  import type { ValidationResult } from './schemas/validation-result.js';
32
33
  import { issueLocation, matchesGlobPattern, splitHrefAnchor } from './utils.js';
33
34
 
35
+ /**
36
+ * Typed error thrown when two resources produce the same ID.
37
+ *
38
+ * Carries the authoritative id and both paths so callers can record accurate
39
+ * issue data without re-deriving the id from the file path (which would be
40
+ * wrong when the id came from a frontmatter `idField` value).
41
+ */
42
+ export class DuplicateResourceIdError extends Error {
43
+ readonly id: string;
44
+ readonly existingPath: string;
45
+ readonly conflictingPath: string;
46
+
47
+ constructor(id: string, conflictingPath: string, existingPath: string) {
48
+ super(`Duplicate resource ID '${id}': '${conflictingPath}' conflicts with '${existingPath}'`);
49
+ this.name = 'DuplicateResourceIdError';
50
+ this.id = id;
51
+ this.existingPath = existingPath;
52
+ this.conflictingPath = conflictingPath;
53
+ }
54
+ }
55
+
34
56
  /**
35
57
  * Options for crawling directories to add resources.
36
58
  */
@@ -169,6 +191,12 @@ export class ResourceRegistry implements ResourceCollectionInterface {
169
191
  */
170
192
  private readonly frontmatterExternalUrlsByResource: Map<string, FrontmatterExternalUrl[]> = new Map();
171
193
 
194
+ /**
195
+ * Collisions recorded by addResources() when two files produce the same resource id.
196
+ * Cleared by clear(). Surfaced as DUPLICATE_RESOURCE_ID issues in validate().
197
+ */
198
+ private duplicateIdCollisions: Array<{ id: string; existingPath: string; conflictingPath: string }> = [];
199
+
172
200
  constructor(options?: ResourceRegistryOptions) {
173
201
  if (options?.baseDir !== undefined) {
174
202
  this.baseDir = options.baseDir;
@@ -233,9 +261,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
233
261
  // Check for duplicate ID
234
262
  const existingById = registry.resourcesById.get(resource.id);
235
263
  if (existingById) {
236
- throw new Error(
237
- `Duplicate resource ID '${resource.id}': '${resource.filePath}' conflicts with '${existingById.filePath}'`
238
- );
264
+ throw new DuplicateResourceIdError(resource.id, resource.filePath, existingById.filePath);
239
265
  }
240
266
 
241
267
  // Add to path index
@@ -304,8 +330,12 @@ export class ResourceRegistry implements ResourceCollectionInterface {
304
330
  // Normalize path to absolute
305
331
  const absolutePath = safePath.resolve(filePath);
306
332
 
307
- // Parse the markdown file (needed before ID generation for frontmatter lookup)
308
- const parseResult = await parseMarkdown(absolutePath);
333
+ // Parse the file HTML or markdown depending on extension
334
+ const lowerPath = absolutePath.toLowerCase();
335
+ const isHtml = lowerPath.endsWith('.html') || lowerPath.endsWith('.htm');
336
+ const parseResult = isHtml
337
+ ? await parseHtml(absolutePath)
338
+ : await parseMarkdown(absolutePath);
309
339
 
310
340
  // Generate ID using priority chain: frontmatter field → relative path → filename stem
311
341
  const id = this.generateId(absolutePath, parseResult.frontmatter);
@@ -313,9 +343,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
313
343
  // Check for duplicate ID (allow re-adding same file path)
314
344
  const existingById = this.resourcesById.get(id);
315
345
  if (existingById && existingById.filePath !== absolutePath) {
316
- throw new Error(
317
- `Duplicate resource ID '${id}': '${absolutePath}' conflicts with '${existingById.filePath}'`
318
- );
346
+ throw new DuplicateResourceIdError(id, absolutePath, existingById.filePath);
319
347
  }
320
348
 
321
349
  // Get file modified time
@@ -336,6 +364,8 @@ export class ResourceRegistry implements ResourceCollectionInterface {
336
364
  filePath: absolutePath,
337
365
  links: parseResult.links,
338
366
  headings: parseResult.headings,
367
+ ...(parseResult.anchors !== undefined && { anchors: parseResult.anchors }),
368
+ ...(parseResult.parseErrors !== undefined && { parseErrors: parseResult.parseErrors }),
339
369
  ...(parseResult.frontmatter !== undefined && { frontmatter: parseResult.frontmatter }),
340
370
  ...(parseResult.frontmatterError !== undefined && { frontmatterError: parseResult.frontmatterError }),
341
371
  sizeBytes: parseResult.sizeBytes,
@@ -372,7 +402,20 @@ export class ResourceRegistry implements ResourceCollectionInterface {
372
402
  async addResources(filePaths: string[]): Promise<ResourceMetadata[]> {
373
403
  const results: ResourceMetadata[] = [];
374
404
  for (const fp of filePaths) {
375
- results.push(await this.addResource(fp));
405
+ try {
406
+ results.push(await this.addResource(fp));
407
+ } catch (error) {
408
+ if (error instanceof DuplicateResourceIdError) {
409
+ this.duplicateIdCollisions.push({
410
+ id: error.id,
411
+ existingPath: error.existingPath,
412
+ conflictingPath: error.conflictingPath,
413
+ });
414
+ // First-added wins; skip conflicting file and continue crawling.
415
+ } else {
416
+ throw error;
417
+ }
418
+ }
376
419
  }
377
420
  return results;
378
421
  }
@@ -396,7 +439,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
396
439
  async crawl(options: CrawlOptions): Promise<ResourceMetadata[]> {
397
440
  const {
398
441
  baseDir,
399
- include = ['**/*.md'],
442
+ include = ['**/*.md', '**/*.html', '**/*.htm'],
400
443
  exclude = ['**/node_modules/**', '**/.git/**', '**/dist/**'],
401
444
  followSymlinks = false,
402
445
  } = options;
@@ -442,12 +485,48 @@ export class ResourceRegistry implements ResourceCollectionInterface {
442
485
  return issues;
443
486
  }
444
487
 
488
+ /**
489
+ * Emit MALFORMED_HTML issues from each resource's HTML parse errors.
490
+ * @private
491
+ */
492
+ private collectHtmlParseErrors(): ValidationIssue[] {
493
+ const issues: ValidationIssue[] = [];
494
+ for (const resource of this.resourcesByPath.values()) {
495
+ for (const parseError of resource.parseErrors ?? []) {
496
+ issues.push(
497
+ createRegistryIssue(
498
+ 'MALFORMED_HTML',
499
+ `Malformed HTML: ${parseError.message}`,
500
+ {
501
+ location: issueLocation(resource.filePath, this.baseDir),
502
+ ...(parseError.line !== undefined && { line: parseError.line }),
503
+ },
504
+ ),
505
+ );
506
+ }
507
+ }
508
+ return issues;
509
+ }
510
+
511
+ /**
512
+ * Emit DUPLICATE_RESOURCE_ID errors for collisions recorded by addResources().
513
+ * @private
514
+ */
515
+ private collectDuplicateIdErrors(): ValidationIssue[] {
516
+ return this.duplicateIdCollisions.map(({ id, existingPath, conflictingPath }) =>
517
+ createRegistryIssue(
518
+ 'DUPLICATE_RESOURCE_ID',
519
+ `Two files resolve to the same resource id '${id}': '${issueLocation(existingPath, this.baseDir)}' and '${issueLocation(conflictingPath, this.baseDir)}'. Rename one of the files so they produce distinct resource ids.`,
520
+ ),
521
+ );
522
+ }
523
+
445
524
  /**
446
525
  * Validate all links in all resources.
447
526
  * @private
448
527
  */
449
528
  private async validateAllLinks(
450
- headingsByFile: Map<string, HeadingNode[]>,
529
+ fragmentsByFile: FragmentIndex,
451
530
  skipGitIgnoreCheck: boolean
452
531
  ): Promise<ValidationIssue[]> {
453
532
  const issues: ValidationIssue[] = [];
@@ -463,7 +542,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
463
542
  ...(this.gitTracker !== undefined && { gitTracker: this.gitTracker })
464
543
  };
465
544
 
466
- const issue = await validateLink(link, resource.filePath, headingsByFile, validateOptions);
545
+ const issue = await validateLink(link, resource.filePath, fragmentsByFile, validateOptions);
467
546
  if (issue) {
468
547
  issues.push(issue);
469
548
  }
@@ -501,7 +580,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
501
580
  * @private
502
581
  */
503
582
  private async validateCollectionFrontmatter(
504
- headingsByFile: Map<string, HeadingNode[]>,
583
+ fragmentsByFile: FragmentIndex,
505
584
  skipGitIgnoreCheck: boolean,
506
585
  ): Promise<ValidationIssue[]> {
507
586
  const issues: ValidationIssue[] = [];
@@ -523,7 +602,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
523
602
  const collectionIssues = await this.validateResourceCollectionSchemas(
524
603
  resource,
525
604
  fsPromises,
526
- headingsByFile,
605
+ fragmentsByFile,
527
606
  skipGitIgnoreCheck,
528
607
  );
529
608
  issues.push(...collectionIssues);
@@ -539,7 +618,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
539
618
  private async validateResourceCollectionSchemas(
540
619
  resource: ResourceMetadata,
541
620
  fsModule: typeof fs,
542
- headingsByFile: Map<string, HeadingNode[]>,
621
+ fragmentsByFile: FragmentIndex,
543
622
  skipGitIgnoreCheck: boolean,
544
623
  ): Promise<ValidationIssue[]> {
545
624
  const issues: ValidationIssue[] = [];
@@ -560,7 +639,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
560
639
  resource,
561
640
  collection.validation,
562
641
  fsModule,
563
- headingsByFile,
642
+ fragmentsByFile,
564
643
  skipGitIgnoreCheck,
565
644
  );
566
645
  issues.push(...collectionIssues);
@@ -577,7 +656,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
577
656
  resource: ResourceMetadata,
578
657
  validation: NonNullable<NonNullable<ProjectConfig['resources']>['collections']>[string]['validation'],
579
658
  fsModule: typeof fs,
580
- headingsByFile: Map<string, HeadingNode[]>,
659
+ fragmentsByFile: FragmentIndex,
581
660
  skipGitIgnoreCheck: boolean,
582
661
  ): Promise<ValidationIssue[]> {
583
662
  if (!validation?.frontmatterSchema) {
@@ -620,7 +699,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
620
699
  resource.frontmatter,
621
700
  schema,
622
701
  resource.filePath,
623
- headingsByFile,
702
+ fragmentsByFile,
624
703
  linkOptions,
625
704
  );
626
705
  issues.push(...linkIssues);
@@ -679,8 +758,8 @@ export class ResourceRegistry implements ResourceCollectionInterface {
679
758
  async validate(options?: ValidateOptions): Promise<ValidationResult> {
680
759
  const startTime = Date.now();
681
760
 
682
- // Build headings map for validation
683
- const headingsByFile = this.buildHeadingsByFileMap();
761
+ // Build fragment index for anchor validation
762
+ const fragmentsByFile = this.buildFragmentIndex();
684
763
 
685
764
  // Reset frontmatter external URL state for this validation run
686
765
  this.frontmatterExternalUrlsByResource.clear();
@@ -688,19 +767,21 @@ export class ResourceRegistry implements ResourceCollectionInterface {
688
767
  // Collect all validation issues
689
768
  const issues: ValidationIssue[] = [];
690
769
 
691
- // Check for YAML parsing errors first
692
- issues.push(...this.collectYamlErrors());
770
+ // Surface parse-time diagnostics: YAML frontmatter errors first, then HTML
771
+ // well-formedness, then duplicate-id collisions. Combined into one push() call
772
+ // (SonarCloud S7778).
773
+ issues.push(...this.collectYamlErrors(), ...this.collectHtmlParseErrors(), ...this.collectDuplicateIdErrors());
693
774
 
694
775
  // Validate each link in each resource
695
776
  const linkIssues = await this.validateAllLinks(
696
- headingsByFile,
777
+ fragmentsByFile,
697
778
  options?.skipGitIgnoreCheck ?? false
698
779
  );
699
780
  issues.push(...linkIssues);
700
781
 
701
782
  // Per-collection frontmatter validation
702
783
  const collectionFrontmatterIssues = await this.validateCollectionFrontmatter(
703
- headingsByFile,
784
+ fragmentsByFile,
704
785
  options?.skipGitIgnoreCheck ?? false,
705
786
  );
706
787
  issues.push(...collectionFrontmatterIssues);
@@ -1044,6 +1125,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
1044
1125
  this.resourcesById.clear();
1045
1126
  this.resourcesByName.clear();
1046
1127
  this.resourcesByChecksum.clear();
1128
+ this.duplicateIdCollisions = [];
1047
1129
  }
1048
1130
 
1049
1131
  /**
@@ -1253,14 +1335,20 @@ export class ResourceRegistry implements ResourceCollectionInterface {
1253
1335
  }
1254
1336
 
1255
1337
  /**
1256
- * Build a map of file paths to their heading trees.
1257
- *
1258
- * Used for link validation.
1338
+ * Build a format-neutral fragment index for anchor validation: each file's
1339
+ * absolute path → the set of valid fragment targets. Markdown contributes
1340
+ * heading slugs (lowercased); HTML contributes its `id`/`name` anchors.
1259
1341
  */
1260
- private buildHeadingsByFileMap(): Map<string, HeadingNode[]> {
1261
- const map = new Map<string, HeadingNode[]>();
1342
+ private buildFragmentIndex(): FragmentIndex {
1343
+ const map: FragmentIndex = new Map();
1262
1344
  for (const resource of this.resourcesByPath.values()) {
1263
- map.set(resource.filePath, resource.headings);
1345
+ const fragments = new Set<string>();
1346
+ collectHeadingSlugs(resource.headings, fragments);
1347
+ for (const anchor of resource.anchors ?? []) {
1348
+ fragments.add(anchor);
1349
+ }
1350
+ // Policy (case-sensitive ids vs folded slugs) is derived from the file type.
1351
+ map.set(resource.filePath, fragmentIndexEntry(resource.filePath, fragments));
1264
1352
  }
1265
1353
  return map;
1266
1354
  }
@@ -1318,29 +1406,47 @@ export class ResourceRegistry implements ResourceCollectionInterface {
1318
1406
  }
1319
1407
  }
1320
1408
 
1409
+ /** Recursively collect lowercased heading slugs into `out`. */
1410
+ function collectHeadingSlugs(headings: HeadingNode[], out: Set<string>): void {
1411
+ for (const heading of headings) {
1412
+ out.add(heading.slug.toLowerCase());
1413
+ if (heading.children) {
1414
+ collectHeadingSlugs(heading.children, out);
1415
+ }
1416
+ }
1417
+ }
1418
+
1321
1419
  /**
1322
1420
  * Generate an ID from a file path.
1323
1421
  *
1422
+ * Every resource id includes a `-<ext>` suffix derived from the file extension
1423
+ * (dot stripped, lowercased). This makes ids from different file types distinct
1424
+ * even when the stem is identical (e.g. `foo.md` → `foo-md`, `foo.html` → `foo-html`).
1425
+ * Extensionless files (e.g. `Makefile`) receive no suffix.
1426
+ *
1324
1427
  * When `baseDir` is provided, computes a relative path from baseDir and uses the full
1325
1428
  * directory structure in the ID. When no `baseDir`, uses the filename stem only.
1326
1429
  *
1327
1430
  * @param filePath - Absolute file path
1328
1431
  * @param baseDir - Base directory for relative path computation (optional)
1329
- * @returns Generated ID in kebab-case
1432
+ * @returns Generated ID in kebab-case with `-<ext>` suffix
1330
1433
  *
1331
1434
  * @example
1332
1435
  * ```typescript
1333
- * // Without baseDir: filename stem only
1334
- * generateIdFromPath('/project/docs/User Guide.md') // 'user-guide'
1335
- * generateIdFromPath('/project/README.md') // 'readme'
1436
+ * // Without baseDir: filename stem + extension suffix
1437
+ * generateIdFromPath('/project/docs/User Guide.md') // 'user-guide-md'
1438
+ * generateIdFromPath('/project/README.md') // 'readme-md'
1439
+ * generateIdFromPath('/project/page.html') // 'page-html'
1440
+ * generateIdFromPath('/project/Makefile') // 'makefile'
1336
1441
  *
1337
- * // With baseDir: relative path
1338
- * generateIdFromPath('/project/docs/concepts/core/overview.md', '/project/docs') // 'concepts-core-overview'
1339
- * generateIdFromPath('/project/docs/guide.md', '/project/docs') // 'guide'
1442
+ * // With baseDir: relative path + extension suffix
1443
+ * generateIdFromPath('/project/docs/concepts/core/overview.md', '/project/docs') // 'concepts-core-overview-md'
1444
+ * generateIdFromPath('/project/docs/guide.md', '/project/docs') // 'guide-md'
1340
1445
  * ```
1341
1446
  */
1342
1447
  export function generateIdFromPath(filePath: string, baseDir?: string): string {
1343
1448
  let rawId: string;
1449
+ let extSuffix = '';
1344
1450
 
1345
1451
  if (baseDir) {
1346
1452
  // Compute relative path from baseDir, remove extension
@@ -1349,11 +1455,23 @@ export function generateIdFromPath(filePath: string, baseDir?: string): string {
1349
1455
  const withoutExt = ext ? relativePath.slice(0, -ext.length) : relativePath;
1350
1456
  // Normalize path separators to forward slashes (cross-platform), then replace with hyphens
1351
1457
  rawId = toForwardSlash(withoutExt).replaceAll('/', '-');
1458
+ if (ext) {
1459
+ // Strip the leading dot; lowercasing happens in the kebab pipeline below
1460
+ extSuffix = `-${ext.slice(1)}`;
1461
+ }
1352
1462
  } else {
1353
1463
  // Fallback: basename only (no directory context)
1354
- rawId = path.basename(filePath, path.extname(filePath));
1464
+ const ext = path.extname(filePath);
1465
+ rawId = path.basename(filePath, ext);
1466
+ if (ext) {
1467
+ extSuffix = `-${ext.slice(1)}`;
1468
+ }
1355
1469
  }
1356
1470
 
1471
+ // Append extension suffix before the kebab pipeline so hyphen-collapse and
1472
+ // trim apply uniformly (e.g. suffixed-.md → 'suffixed--md' → 'suffixed-md')
1473
+ rawId = `${rawId}${extSuffix}`;
1474
+
1357
1475
  // Convert to kebab-case:
1358
1476
  // 1. Replace underscores and spaces with hyphens
1359
1477
  // 2. Convert to lowercase
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Zod schema for `resources.linkAuth` config.
3
+ *
4
+ * Validates the shape of an adopter's linkAuth configuration: providers
5
+ * (each either a `use: <macro>` reference or a full inline provider) plus
6
+ * an optional cache config. Strict — typos in provider field names are
7
+ * errors, per Postel's law ("writing our output → conservative").
8
+ *
9
+ * Provider entries accept EITHER:
10
+ * - `{ use: <name>, ...overrides }` — reference a shipped macro by name;
11
+ * override fields are deep-merged on top at expansion time. Overrides
12
+ * are NOT strictly typed at this layer because they must match the
13
+ * macro's shape, which we cannot see until expansion. A typo in an
14
+ * override silently no-ops; the full provider shape is validated
15
+ * after expansion (slice 2 wires that).
16
+ * - A full inline `{ match, rewrite, auth, token, check }` — every field
17
+ * required, every nested object strict.
18
+ *
19
+ * Mirrors the runtime types in `@vibe-agent-toolkit/utils`'s `link-auth/`
20
+ * module. Keep these aligned: a config that parses here must satisfy the
21
+ * `Provider` / `LinkAuthConfig` interfaces over there.
22
+ *
23
+ * Per design issue #113 §4 (vocabulary) and §5 (macros).
24
+ */
25
+
26
+ import { z } from 'zod';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Leaf types
30
+ // ---------------------------------------------------------------------------
31
+
32
+ export const ProviderMatchSchema = z
33
+ .object({
34
+ host: z.string().min(1).describe('Glob matched against URL hostname'),
35
+ excludeHost: z
36
+ .array(z.string().min(1))
37
+ .optional()
38
+ .describe('Globs that, if any match the hostname, exclude this provider'),
39
+ })
40
+ .strict()
41
+ .describe('Provider selection — match.host + optional excludeHost globs');
42
+
43
+ export const RewriteRuleSchema = z
44
+ .object({
45
+ when: z.string().min(1).describe('Regex source (RFC-style, JavaScript-compatible) matched against the URL'),
46
+ vars: z
47
+ .record(z.string(), z.string())
48
+ .optional()
49
+ .describe('Computed variables, each a template that references captures from `when`'),
50
+ to: z.string().min(1).describe('URL template — interpolates ${capture} and ${var}'),
51
+ })
52
+ .strict()
53
+ .describe('A single rewrite rule (one entry in a provider\'s ordered rewrite list)');
54
+
55
+ export const ProviderAuthSchema = z
56
+ .object({
57
+ headers: z
58
+ .record(z.string(), z.string())
59
+ .describe('Header name → value template. Values interpolate ${token} and any capture/var.'),
60
+ })
61
+ .strict()
62
+ .describe('Auth headers attached to the authenticated fetch');
63
+
64
+ export const TokenSourceSchema = z
65
+ .union([
66
+ z.object({ env: z.string().min(1) }).strict(),
67
+ z
68
+ .object({
69
+ command: z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]),
70
+ })
71
+ .strict(),
72
+ ])
73
+ .describe('A single token source — env var, or argv command (preferred), or convenience string');
74
+
75
+ export const ProviderCheckSchema = z
76
+ .object({
77
+ method: z.enum(['GET', 'HEAD']),
78
+ aliveStatus: z
79
+ .array(z.number().int().min(100).max(599))
80
+ .min(1)
81
+ .describe('HTTP status codes meaning the URL is alive (per host semantics)'),
82
+ notFoundMeaning: z
83
+ .enum(['ambiguous', 'dead'])
84
+ .describe(
85
+ 'Per-host: does 404 mean "dead" (honest 404 host like Graph) or "ambiguous" (masking host like GitHub)?',
86
+ ),
87
+ })
88
+ .strict()
89
+ .describe('Status-to-outcome classifier (consumed by the resources layer, not the pure engine)');
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Provider entry (inline OR macro reference)
93
+ // ---------------------------------------------------------------------------
94
+
95
+ const InlineProviderSchema = z
96
+ .object({
97
+ match: ProviderMatchSchema,
98
+ rewrite: z.array(RewriteRuleSchema).min(1).describe('Ordered rewrite rules — first matching wins'),
99
+ auth: ProviderAuthSchema,
100
+ token: z.array(TokenSourceSchema).describe('Ordered token sources — first non-empty wins'),
101
+ check: ProviderCheckSchema,
102
+ })
103
+ .strict();
104
+
105
+ const MacroProviderSchema = z
106
+ .object({
107
+ use: z.string().min(1).describe('Macro name (e.g. "github", "sharepoint")'),
108
+ })
109
+ .passthrough()
110
+ .describe(
111
+ 'Reference a shipped macro by name. Override fields (match/rewrite/auth/token/check) deep-merge on top at expansion time.',
112
+ );
113
+
114
+ export const ProviderEntrySchema = z
115
+ .union([MacroProviderSchema, InlineProviderSchema])
116
+ .describe('A provider entry — either { use: <macro>, ...overrides } or a full inline provider');
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Top-level linkAuth config
120
+ // ---------------------------------------------------------------------------
121
+
122
+ export const LinkAuthCacheSchema = z
123
+ .object({
124
+ ttlMinutes: z
125
+ .number()
126
+ .int()
127
+ .positive()
128
+ .optional()
129
+ .describe('Content cache TTL in minutes (default 30 — used by slice 3 content-fetch)'),
130
+ })
131
+ .strict();
132
+
133
+ export const LinkAuthConfigSchema = z
134
+ .object({
135
+ cache: LinkAuthCacheSchema.optional(),
136
+ providers: z
137
+ .array(ProviderEntrySchema)
138
+ .describe('Ordered list of providers — first claiming-by-host wins'),
139
+ })
140
+ .strict()
141
+ .describe('`resources.linkAuth` — authenticated external link resolution config');
142
+
143
+ export type LinkAuthConfig = z.infer<typeof LinkAuthConfigSchema>;
144
+ export type ProviderEntry = z.infer<typeof ProviderEntrySchema>;
145
+ export type RewriteRule = z.infer<typeof RewriteRuleSchema>;
146
+ export type TokenSource = z.infer<typeof TokenSourceSchema>;
@@ -1,6 +1,8 @@
1
1
  import { ValidationConfigSchema } from '@vibe-agent-toolkit/agent-schema';
2
2
  import { z } from 'zod';
3
3
 
4
+ import { LinkAuthConfigSchema } from './link-auth.js';
5
+
4
6
  /**
5
7
  * Official semver regex from https://semver.org/ (anchored).
6
8
  *
@@ -98,6 +100,8 @@ export const ResourcesConfigSchema = z.object({
98
100
  .describe('Named collections of resources'),
99
101
  validation: ValidationConfigSchema.optional()
100
102
  .describe('Validation framework config: severity overrides and per-code allow entries (applied inside ResourceRegistry.validate)'),
103
+ linkAuth: LinkAuthConfigSchema.optional()
104
+ .describe('Authenticated external link resolution config (issue #113 / link-auth engine)'),
101
105
  }).describe('Resources section of project configuration');
102
106
 
103
107
  export type ResourcesConfig = z.infer<typeof ResourcesConfigSchema>;
@@ -6,6 +6,7 @@ import { SHA256Schema } from './checksum.js';
6
6
  * Type of link found in markdown resources.
7
7
  *
8
8
  * - `local_file`: Link to a local file (relative or absolute path)
9
+ * - `local_directory`: Link to a local directory, e.g. a ref ending in `/`
9
10
  * - `anchor`: Link to a heading anchor (e.g., #heading-slug)
10
11
  * - `external`: HTTP/HTTPS URL to external resource
11
12
  * - `email`: Mailto link
@@ -13,6 +14,7 @@ import { SHA256Schema } from './checksum.js';
13
14
  */
14
15
  export const LinkTypeSchema = z.enum([
15
16
  'local_file',
17
+ 'local_directory',
16
18
  'anchor',
17
19
  'external',
18
20
  'email',
@@ -36,6 +38,16 @@ export const LinkNodeTypeSchema = z.enum([
36
38
 
37
39
  export type LinkNodeType = z.infer<typeof LinkNodeTypeSchema>;
38
40
 
41
+ /**
42
+ * Zod schema for an HTML well-formedness diagnostic.
43
+ */
44
+ export const HtmlParseErrorSchema = z.object({
45
+ message: z.string().describe('parse5 error code (e.g. "missing-end-tag")'),
46
+ line: z.number().int().positive().optional().describe('1-based source line, when known'),
47
+ }).describe('HTML well-formedness diagnostic');
48
+
49
+ export type HtmlParseError = z.infer<typeof HtmlParseErrorSchema>;
50
+
39
51
  /**
40
52
  * Represents a heading node in the document's table of contents.
41
53
  *
@@ -96,6 +108,10 @@ export const ResourceMetadataSchema = z.object({
96
108
  filePath: z.string().describe('Absolute path to the resource file'),
97
109
  links: z.array(ResourceLinkSchema).describe('All links found in the resource'),
98
110
  headings: z.array(HeadingNodeSchema).describe('Document table of contents (top-level headings only; children are nested)'),
111
+ anchors: z.array(z.string()).optional()
112
+ .describe('Fragment targets for anchor validation (HTML id/name attributes)'),
113
+ parseErrors: z.array(HtmlParseErrorSchema).optional()
114
+ .describe('HTML well-formedness diagnostics (populated for HTML resources only)'),
99
115
  frontmatter: z.record(z.string(), z.unknown()).optional()
100
116
  .describe('Parsed YAML frontmatter (if present in markdown file)'),
101
117
  frontmatterError: z.string().optional()
@@ -106,6 +122,6 @@ export const ResourceMetadataSchema = z.object({
106
122
  checksum: SHA256Schema.describe('SHA-256 checksum of file content'),
107
123
  collections: z.array(z.string()).optional()
108
124
  .describe('Collection names this resource belongs to (populated when using config-based discovery)'),
109
- }).describe('Complete metadata for a markdown resource');
125
+ }).strict().describe('Complete metadata for a markdown resource');
110
126
 
111
127
  export type ResourceMetadata = z.infer<typeof ResourceMetadataSchema>;