@vibe-agent-toolkit/resources 0.1.38 → 0.1.39-rc.10

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 (75) hide show
  1. package/README.md +33 -24
  2. package/dist/frontmatter-link-validator.d.ts +9 -9
  3. package/dist/frontmatter-link-validator.d.ts.map +1 -1
  4. package/dist/frontmatter-link-validator.js +28 -27
  5. package/dist/frontmatter-link-validator.js.map +1 -1
  6. package/dist/frontmatter-validator.d.ts +3 -2
  7. package/dist/frontmatter-validator.d.ts.map +1 -1
  8. package/dist/frontmatter-validator.js +8 -14
  9. package/dist/frontmatter-validator.js.map +1 -1
  10. package/dist/html-link-parser.d.ts +47 -0
  11. package/dist/html-link-parser.d.ts.map +1 -0
  12. package/dist/html-link-parser.js +111 -0
  13. package/dist/html-link-parser.js.map +1 -0
  14. package/dist/html-transform.d.ts +45 -0
  15. package/dist/html-transform.d.ts.map +1 -0
  16. package/dist/html-transform.js +116 -0
  17. package/dist/html-transform.js.map +1 -0
  18. package/dist/index.d.ts +5 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +4 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/link-parser.d.ts +26 -2
  23. package/dist/link-parser.d.ts.map +1 -1
  24. package/dist/link-parser.js +40 -6
  25. package/dist/link-parser.js.map +1 -1
  26. package/dist/link-validator.d.ts +53 -6
  27. package/dist/link-validator.d.ts.map +1 -1
  28. package/dist/link-validator.js +100 -131
  29. package/dist/link-validator.js.map +1 -1
  30. package/dist/multi-schema-validator.d.ts.map +1 -1
  31. package/dist/multi-schema-validator.js +6 -8
  32. package/dist/multi-schema-validator.js.map +1 -1
  33. package/dist/resource-registry.d.ts +56 -13
  34. package/dist/resource-registry.d.ts.map +1 -1
  35. package/dist/resource-registry.js +165 -66
  36. package/dist/resource-registry.js.map +1 -1
  37. package/dist/schemas/link-auth.d.ts +382 -0
  38. package/dist/schemas/link-auth.d.ts.map +1 -0
  39. package/dist/schemas/link-auth.js +124 -0
  40. package/dist/schemas/link-auth.js.map +1 -0
  41. package/dist/schemas/project-config.d.ts +3277 -171
  42. package/dist/schemas/project-config.d.ts.map +1 -1
  43. package/dist/schemas/project-config.js +68 -0
  44. package/dist/schemas/project-config.js.map +1 -1
  45. package/dist/schemas/resource-metadata.d.ts +46 -10
  46. package/dist/schemas/resource-metadata.d.ts.map +1 -1
  47. package/dist/schemas/resource-metadata.js +14 -1
  48. package/dist/schemas/resource-metadata.js.map +1 -1
  49. package/dist/schemas/validation-result.d.ts +36 -57
  50. package/dist/schemas/validation-result.d.ts.map +1 -1
  51. package/dist/schemas/validation-result.js +5 -27
  52. package/dist/schemas/validation-result.js.map +1 -1
  53. package/dist/types/resources.d.ts +1 -1
  54. package/dist/types/resources.d.ts.map +1 -1
  55. package/dist/types/resources.js.map +1 -1
  56. package/dist/utils.d.ts +10 -0
  57. package/dist/utils.d.ts.map +1 -1
  58. package/dist/utils.js +14 -0
  59. package/dist/utils.js.map +1 -1
  60. package/package.json +4 -3
  61. package/src/frontmatter-link-validator.ts +28 -30
  62. package/src/frontmatter-validator.ts +19 -16
  63. package/src/html-link-parser.ts +140 -0
  64. package/src/html-transform.ts +157 -0
  65. package/src/index.ts +10 -1
  66. package/src/link-parser.ts +60 -11
  67. package/src/link-validator.ts +175 -145
  68. package/src/multi-schema-validator.ts +10 -8
  69. package/src/resource-registry.ts +205 -72
  70. package/src/schemas/link-auth.ts +146 -0
  71. package/src/schemas/project-config.ts +76 -0
  72. package/src/schemas/resource-metadata.ts +17 -1
  73. package/src/schemas/validation-result.ts +5 -29
  74. package/src/types/resources.ts +2 -1
  75. package/src/utils.ts +15 -0
@@ -11,6 +11,7 @@
11
11
  import type fs from 'node:fs/promises';
12
12
  import path from 'node:path';
13
13
 
14
+ import { createRegistryIssue, type IssueCode, runValidationFramework, type ValidationConfig, type ValidationIssue } from '@vibe-agent-toolkit/agent-schema';
14
15
  import { crawlDirectory, type CrawlOptions as UtilsCrawlOptions, type GitTracker, normalizedTmpdir, resolveAssetReference, safePath, toForwardSlash } from '@vibe-agent-toolkit/utils';
15
16
 
16
17
  import { calculateChecksum } from './checksum.js';
@@ -21,14 +22,36 @@ import {
21
22
  type FrontmatterExternalUrl,
22
23
  } from './frontmatter-link-validator.js';
23
24
  import { validateFrontmatter } from './frontmatter-validator.js';
25
+ import { parseHtml } from './html-link-parser.js';
24
26
  import { parseMarkdown } from './link-parser.js';
25
- import { validateLink, type ValidateLinkOptions } from './link-validator.js';
27
+ import { fragmentIndexEntry, validateLink, type FragmentIndex, type ValidateLinkOptions } from './link-validator.js';
26
28
  import type { ResourceCollectionInterface } from './resource-collection-interface.js';
27
29
  import type { SHA256 } from './schemas/checksum.js';
28
30
  import type { ProjectConfig } from './schemas/project-config.js';
29
31
  import type { HeadingNode, ResourceMetadata } from './schemas/resource-metadata.js';
30
- import type { ValidationIssue, ValidationResult } from './schemas/validation-result.js';
31
- import { matchesGlobPattern, splitHrefAnchor } from './utils.js';
32
+ import type { ValidationResult } from './schemas/validation-result.js';
33
+ import { issueLocation, matchesGlobPattern, splitHrefAnchor } from './utils.js';
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
+ }
32
55
 
33
56
  /**
34
57
  * Options for crawling directories to add resources.
@@ -72,6 +95,13 @@ export interface ValidateOptions {
72
95
  checkExternalUrls?: boolean;
73
96
  /** Disable cache for external URL checks (default: false) */
74
97
  noCache?: boolean;
98
+ /**
99
+ * Validation framework config (severity overrides + per-code allow entries).
100
+ * Applied INSIDE validate() via runValidationFramework — the library, not the
101
+ * CLI, resolves severity and drops ignored issues. Defaults to `{}` (no
102
+ * overrides: every issue keeps its registry default severity).
103
+ */
104
+ validationConfig?: ValidationConfig;
75
105
  }
76
106
 
77
107
  /**
@@ -161,6 +191,12 @@ export class ResourceRegistry implements ResourceCollectionInterface {
161
191
  */
162
192
  private readonly frontmatterExternalUrlsByResource: Map<string, FrontmatterExternalUrl[]> = new Map();
163
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
+
164
200
  constructor(options?: ResourceRegistryOptions) {
165
201
  if (options?.baseDir !== undefined) {
166
202
  this.baseDir = options.baseDir;
@@ -225,9 +261,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
225
261
  // Check for duplicate ID
226
262
  const existingById = registry.resourcesById.get(resource.id);
227
263
  if (existingById) {
228
- throw new Error(
229
- `Duplicate resource ID '${resource.id}': '${resource.filePath}' conflicts with '${existingById.filePath}'`
230
- );
264
+ throw new DuplicateResourceIdError(resource.id, resource.filePath, existingById.filePath);
231
265
  }
232
266
 
233
267
  // Add to path index
@@ -296,8 +330,12 @@ export class ResourceRegistry implements ResourceCollectionInterface {
296
330
  // Normalize path to absolute
297
331
  const absolutePath = safePath.resolve(filePath);
298
332
 
299
- // Parse the markdown file (needed before ID generation for frontmatter lookup)
300
- 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);
301
339
 
302
340
  // Generate ID using priority chain: frontmatter field → relative path → filename stem
303
341
  const id = this.generateId(absolutePath, parseResult.frontmatter);
@@ -305,9 +343,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
305
343
  // Check for duplicate ID (allow re-adding same file path)
306
344
  const existingById = this.resourcesById.get(id);
307
345
  if (existingById && existingById.filePath !== absolutePath) {
308
- throw new Error(
309
- `Duplicate resource ID '${id}': '${absolutePath}' conflicts with '${existingById.filePath}'`
310
- );
346
+ throw new DuplicateResourceIdError(id, absolutePath, existingById.filePath);
311
347
  }
312
348
 
313
349
  // Get file modified time
@@ -328,6 +364,8 @@ export class ResourceRegistry implements ResourceCollectionInterface {
328
364
  filePath: absolutePath,
329
365
  links: parseResult.links,
330
366
  headings: parseResult.headings,
367
+ ...(parseResult.anchors !== undefined && { anchors: parseResult.anchors }),
368
+ ...(parseResult.parseErrors !== undefined && { parseErrors: parseResult.parseErrors }),
331
369
  ...(parseResult.frontmatter !== undefined && { frontmatter: parseResult.frontmatter }),
332
370
  ...(parseResult.frontmatterError !== undefined && { frontmatterError: parseResult.frontmatterError }),
333
371
  sizeBytes: parseResult.sizeBytes,
@@ -364,7 +402,20 @@ export class ResourceRegistry implements ResourceCollectionInterface {
364
402
  async addResources(filePaths: string[]): Promise<ResourceMetadata[]> {
365
403
  const results: ResourceMetadata[] = [];
366
404
  for (const fp of filePaths) {
367
- 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
+ }
368
419
  }
369
420
  return results;
370
421
  }
@@ -388,7 +439,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
388
439
  async crawl(options: CrawlOptions): Promise<ResourceMetadata[]> {
389
440
  const {
390
441
  baseDir,
391
- include = ['**/*.md'],
442
+ include = ['**/*.md', '**/*.html', '**/*.htm'],
392
443
  exclude = ['**/node_modules/**', '**/.git/**', '**/dist/**'],
393
444
  followSymlinks = false,
394
445
  } = options;
@@ -422,24 +473,60 @@ export class ResourceRegistry implements ResourceCollectionInterface {
422
473
  const issues: ValidationIssue[] = [];
423
474
  for (const resource of this.resourcesByPath.values()) {
424
475
  if (resource.frontmatterError) {
425
- issues.push({
426
- resourcePath: resource.filePath,
427
- line: 1,
428
- type: 'frontmatter_invalid_yaml',
429
- link: '',
430
- message: `Invalid YAML syntax in frontmatter: ${resource.frontmatterError}`,
431
- });
476
+ issues.push(
477
+ createRegistryIssue(
478
+ 'FRONTMATTER_INVALID_YAML',
479
+ `Invalid YAML syntax in frontmatter: ${resource.frontmatterError}`,
480
+ { location: issueLocation(resource.filePath, this.baseDir), line: 1 },
481
+ ),
482
+ );
483
+ }
484
+ }
485
+ return issues;
486
+ }
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
+ );
432
506
  }
433
507
  }
434
508
  return issues;
435
509
  }
436
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
+
437
524
  /**
438
525
  * Validate all links in all resources.
439
526
  * @private
440
527
  */
441
528
  private async validateAllLinks(
442
- headingsByFile: Map<string, HeadingNode[]>,
529
+ fragmentsByFile: FragmentIndex,
443
530
  skipGitIgnoreCheck: boolean
444
531
  ): Promise<ValidationIssue[]> {
445
532
  const issues: ValidationIssue[] = [];
@@ -455,7 +542,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
455
542
  ...(this.gitTracker !== undefined && { gitTracker: this.gitTracker })
456
543
  };
457
544
 
458
- const issue = await validateLink(link, resource.filePath, headingsByFile, validateOptions);
545
+ const issue = await validateLink(link, resource.filePath, fragmentsByFile, validateOptions);
459
546
  if (issue) {
460
547
  issues.push(issue);
461
548
  }
@@ -479,7 +566,9 @@ export class ResourceRegistry implements ResourceCollectionInterface {
479
566
  resource.frontmatter,
480
567
  schema,
481
568
  resource.filePath,
482
- mode
569
+ mode,
570
+ undefined,
571
+ this.baseDir,
483
572
  );
484
573
  issues.push(...frontmatterIssues);
485
574
  }
@@ -491,7 +580,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
491
580
  * @private
492
581
  */
493
582
  private async validateCollectionFrontmatter(
494
- headingsByFile: Map<string, HeadingNode[]>,
583
+ fragmentsByFile: FragmentIndex,
495
584
  skipGitIgnoreCheck: boolean,
496
585
  ): Promise<ValidationIssue[]> {
497
586
  const issues: ValidationIssue[] = [];
@@ -513,7 +602,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
513
602
  const collectionIssues = await this.validateResourceCollectionSchemas(
514
603
  resource,
515
604
  fsPromises,
516
- headingsByFile,
605
+ fragmentsByFile,
517
606
  skipGitIgnoreCheck,
518
607
  );
519
608
  issues.push(...collectionIssues);
@@ -529,7 +618,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
529
618
  private async validateResourceCollectionSchemas(
530
619
  resource: ResourceMetadata,
531
620
  fsModule: typeof fs,
532
- headingsByFile: Map<string, HeadingNode[]>,
621
+ fragmentsByFile: FragmentIndex,
533
622
  skipGitIgnoreCheck: boolean,
534
623
  ): Promise<ValidationIssue[]> {
535
624
  const issues: ValidationIssue[] = [];
@@ -550,7 +639,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
550
639
  resource,
551
640
  collection.validation,
552
641
  fsModule,
553
- headingsByFile,
642
+ fragmentsByFile,
554
643
  skipGitIgnoreCheck,
555
644
  );
556
645
  issues.push(...collectionIssues);
@@ -567,7 +656,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
567
656
  resource: ResourceMetadata,
568
657
  validation: NonNullable<NonNullable<ProjectConfig['resources']>['collections']>[string]['validation'],
569
658
  fsModule: typeof fs,
570
- headingsByFile: Map<string, HeadingNode[]>,
659
+ fragmentsByFile: FragmentIndex,
571
660
  skipGitIgnoreCheck: boolean,
572
661
  ): Promise<ValidationIssue[]> {
573
662
  if (!validation?.frontmatterSchema) {
@@ -593,6 +682,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
593
682
  resource.filePath,
594
683
  mode,
595
684
  schemaPath,
685
+ this.baseDir,
596
686
  );
597
687
 
598
688
  // New: walk URI-family frontmatter values. Default-on; explicit `false` disables.
@@ -609,7 +699,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
609
699
  resource.frontmatter,
610
700
  schema,
611
701
  resource.filePath,
612
- headingsByFile,
702
+ fragmentsByFile,
613
703
  linkOptions,
614
704
  );
615
705
  issues.push(...linkIssues);
@@ -624,13 +714,13 @@ export class ResourceRegistry implements ResourceCollectionInterface {
624
714
  } catch (error) {
625
715
  // Handle missing or invalid schema files gracefully
626
716
  const errorMessage = error instanceof Error ? error.message : String(error);
627
- return [{
628
- resourcePath: resource.filePath,
629
- line: 1,
630
- type: 'frontmatter_schema_error',
631
- link: '',
632
- message: `Failed to load or parse frontmatter schema '${validation.frontmatterSchema}': ${errorMessage}`,
633
- }];
717
+ return [
718
+ createRegistryIssue(
719
+ 'FRONTMATTER_SCHEMA_ERROR',
720
+ `Failed to load or parse frontmatter schema '${validation.frontmatterSchema}': ${errorMessage}`,
721
+ { location: issueLocation(resource.filePath, this.baseDir), line: 1 },
722
+ ),
723
+ ];
634
724
  }
635
725
  }
636
726
 
@@ -668,8 +758,8 @@ export class ResourceRegistry implements ResourceCollectionInterface {
668
758
  async validate(options?: ValidateOptions): Promise<ValidationResult> {
669
759
  const startTime = Date.now();
670
760
 
671
- // Build headings map for validation
672
- const headingsByFile = this.buildHeadingsByFileMap();
761
+ // Build fragment index for anchor validation
762
+ const fragmentsByFile = this.buildFragmentIndex();
673
763
 
674
764
  // Reset frontmatter external URL state for this validation run
675
765
  this.frontmatterExternalUrlsByResource.clear();
@@ -677,19 +767,21 @@ export class ResourceRegistry implements ResourceCollectionInterface {
677
767
  // Collect all validation issues
678
768
  const issues: ValidationIssue[] = [];
679
769
 
680
- // Check for YAML parsing errors first
681
- 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());
682
774
 
683
775
  // Validate each link in each resource
684
776
  const linkIssues = await this.validateAllLinks(
685
- headingsByFile,
777
+ fragmentsByFile,
686
778
  options?.skipGitIgnoreCheck ?? false
687
779
  );
688
780
  issues.push(...linkIssues);
689
781
 
690
782
  // Per-collection frontmatter validation
691
783
  const collectionFrontmatterIssues = await this.validateCollectionFrontmatter(
692
- headingsByFile,
784
+ fragmentsByFile,
693
785
  options?.skipGitIgnoreCheck ?? false,
694
786
  );
695
787
  issues.push(...collectionFrontmatterIssues);
@@ -706,8 +798,11 @@ export class ResourceRegistry implements ResourceCollectionInterface {
706
798
  issues.push(...externalUrlIssues);
707
799
  }
708
800
 
709
- // Count issues (all are errors now)
710
- const errorCount = issues.length;
801
+ // Resolve severity + apply allow-filter INSIDE the library (not the CLI).
802
+ // `emitted` = post-allow-filter, severity-resolved, with `ignore`d dropped.
803
+ const framework = runValidationFramework(issues, options?.validationConfig ?? {});
804
+ const emitted = framework.emitted;
805
+ const errorCount = emitted.length;
711
806
 
712
807
  // Count links by type
713
808
  const linksByType: Record<string, number> = {};
@@ -726,9 +821,10 @@ export class ResourceRegistry implements ResourceCollectionInterface {
726
821
  0
727
822
  ),
728
823
  linksByType,
729
- issues,
824
+ issues: emitted,
730
825
  errorCount,
731
826
  passed: errorCount === 0,
827
+ hasErrors: framework.hasErrors,
732
828
  durationMs,
733
829
  timestamp: new Date(),
734
830
  };
@@ -830,17 +926,17 @@ export class ResourceRegistry implements ResourceCollectionInterface {
830
926
  continue;
831
927
  }
832
928
 
833
- const issueType = this.determineExternalUrlIssueType(result.statusCode, result.error);
929
+ const issueCode = this.determineExternalUrlIssueCode(result.statusCode, result.error);
834
930
  const errorMessage = result.error ?? `HTTP ${result.statusCode}`;
835
931
 
836
932
  for (const location of locations) {
837
- issues.push({
838
- resourcePath: location.resourcePath,
839
- line: location.line,
840
- type: issueType,
841
- link: result.url,
842
- message: `External URL failed: ${errorMessage}`,
843
- });
933
+ issues.push(
934
+ createRegistryIssue(issueCode, `External URL failed: ${errorMessage}`, {
935
+ location: issueLocation(location.resourcePath, this.baseDir),
936
+ link: result.url,
937
+ ...(location.line !== undefined && { line: location.line }),
938
+ }),
939
+ );
844
940
  }
845
941
  }
846
942
 
@@ -848,18 +944,18 @@ export class ResourceRegistry implements ResourceCollectionInterface {
848
944
  }
849
945
 
850
946
  /**
851
- * Determine issue type based on validation error.
947
+ * Determine the registry issue code based on the external-URL validation error.
852
948
  * @private
853
949
  */
854
- private determineExternalUrlIssueType(statusCode: number, error?: string): string {
950
+ private determineExternalUrlIssueCode(statusCode: number, error?: string): IssueCode {
855
951
  if (statusCode === 0) {
856
952
  const errorLower = error?.toString().toLowerCase();
857
953
  if (errorLower?.includes('timeout')) {
858
- return 'external_url_timeout';
954
+ return 'EXTERNAL_URL_TIMEOUT';
859
955
  }
860
- return 'external_url_error';
956
+ return 'EXTERNAL_URL_ERROR';
861
957
  }
862
- return 'external_url_dead';
958
+ return 'EXTERNAL_URL_DEAD';
863
959
  }
864
960
 
865
961
  /**
@@ -1029,6 +1125,7 @@ export class ResourceRegistry implements ResourceCollectionInterface {
1029
1125
  this.resourcesById.clear();
1030
1126
  this.resourcesByName.clear();
1031
1127
  this.resourcesByChecksum.clear();
1128
+ this.duplicateIdCollisions = [];
1032
1129
  }
1033
1130
 
1034
1131
  /**
@@ -1238,14 +1335,20 @@ export class ResourceRegistry implements ResourceCollectionInterface {
1238
1335
  }
1239
1336
 
1240
1337
  /**
1241
- * Build a map of file paths to their heading trees.
1242
- *
1243
- * 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.
1244
1341
  */
1245
- private buildHeadingsByFileMap(): Map<string, HeadingNode[]> {
1246
- const map = new Map<string, HeadingNode[]>();
1342
+ private buildFragmentIndex(): FragmentIndex {
1343
+ const map: FragmentIndex = new Map();
1247
1344
  for (const resource of this.resourcesByPath.values()) {
1248
- 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));
1249
1352
  }
1250
1353
  return map;
1251
1354
  }
@@ -1303,29 +1406,47 @@ export class ResourceRegistry implements ResourceCollectionInterface {
1303
1406
  }
1304
1407
  }
1305
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
+
1306
1419
  /**
1307
1420
  * Generate an ID from a file path.
1308
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
+ *
1309
1427
  * When `baseDir` is provided, computes a relative path from baseDir and uses the full
1310
1428
  * directory structure in the ID. When no `baseDir`, uses the filename stem only.
1311
1429
  *
1312
1430
  * @param filePath - Absolute file path
1313
1431
  * @param baseDir - Base directory for relative path computation (optional)
1314
- * @returns Generated ID in kebab-case
1432
+ * @returns Generated ID in kebab-case with `-<ext>` suffix
1315
1433
  *
1316
1434
  * @example
1317
1435
  * ```typescript
1318
- * // Without baseDir: filename stem only
1319
- * generateIdFromPath('/project/docs/User Guide.md') // 'user-guide'
1320
- * 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'
1321
1441
  *
1322
- * // With baseDir: relative path
1323
- * generateIdFromPath('/project/docs/concepts/core/overview.md', '/project/docs') // 'concepts-core-overview'
1324
- * 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'
1325
1445
  * ```
1326
1446
  */
1327
1447
  export function generateIdFromPath(filePath: string, baseDir?: string): string {
1328
1448
  let rawId: string;
1449
+ let extSuffix = '';
1329
1450
 
1330
1451
  if (baseDir) {
1331
1452
  // Compute relative path from baseDir, remove extension
@@ -1334,11 +1455,23 @@ export function generateIdFromPath(filePath: string, baseDir?: string): string {
1334
1455
  const withoutExt = ext ? relativePath.slice(0, -ext.length) : relativePath;
1335
1456
  // Normalize path separators to forward slashes (cross-platform), then replace with hyphens
1336
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
+ }
1337
1462
  } else {
1338
1463
  // Fallback: basename only (no directory context)
1339
- 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
+ }
1340
1469
  }
1341
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
+
1342
1475
  // Convert to kebab-case:
1343
1476
  // 1. Replace underscores and spaces with hyphens
1344
1477
  // 2. Convert to lowercase