@vibe-agent-toolkit/resources 0.1.38 → 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.
- package/README.md +33 -24
- package/dist/frontmatter-link-validator.d.ts +9 -9
- package/dist/frontmatter-link-validator.d.ts.map +1 -1
- package/dist/frontmatter-link-validator.js +28 -27
- package/dist/frontmatter-link-validator.js.map +1 -1
- package/dist/frontmatter-validator.d.ts +3 -2
- package/dist/frontmatter-validator.d.ts.map +1 -1
- package/dist/frontmatter-validator.js +8 -14
- package/dist/frontmatter-validator.js.map +1 -1
- package/dist/html-link-parser.d.ts +47 -0
- package/dist/html-link-parser.d.ts.map +1 -0
- package/dist/html-link-parser.js +111 -0
- package/dist/html-link-parser.js.map +1 -0
- package/dist/html-transform.d.ts +45 -0
- package/dist/html-transform.d.ts.map +1 -0
- package/dist/html-transform.js +116 -0
- package/dist/html-transform.js.map +1 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/link-parser.d.ts +26 -2
- package/dist/link-parser.d.ts.map +1 -1
- package/dist/link-parser.js +40 -6
- package/dist/link-parser.js.map +1 -1
- package/dist/link-validator.d.ts +53 -6
- package/dist/link-validator.d.ts.map +1 -1
- package/dist/link-validator.js +100 -131
- package/dist/link-validator.js.map +1 -1
- package/dist/multi-schema-validator.d.ts.map +1 -1
- package/dist/multi-schema-validator.js +6 -8
- package/dist/multi-schema-validator.js.map +1 -1
- package/dist/resource-registry.d.ts +56 -13
- package/dist/resource-registry.d.ts.map +1 -1
- package/dist/resource-registry.js +165 -66
- package/dist/resource-registry.js.map +1 -1
- package/dist/schemas/link-auth.d.ts +382 -0
- package/dist/schemas/link-auth.d.ts.map +1 -0
- package/dist/schemas/link-auth.js +124 -0
- package/dist/schemas/link-auth.js.map +1 -0
- package/dist/schemas/project-config.d.ts +763 -171
- package/dist/schemas/project-config.d.ts.map +1 -1
- package/dist/schemas/project-config.js +5 -0
- package/dist/schemas/project-config.js.map +1 -1
- package/dist/schemas/resource-metadata.d.ts +46 -10
- package/dist/schemas/resource-metadata.d.ts.map +1 -1
- package/dist/schemas/resource-metadata.js +14 -1
- package/dist/schemas/resource-metadata.js.map +1 -1
- package/dist/schemas/validation-result.d.ts +36 -57
- package/dist/schemas/validation-result.d.ts.map +1 -1
- package/dist/schemas/validation-result.js +5 -27
- package/dist/schemas/validation-result.js.map +1 -1
- package/dist/types/resources.d.ts +1 -1
- package/dist/types/resources.d.ts.map +1 -1
- package/dist/types/resources.js.map +1 -1
- package/dist/utils.d.ts +10 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +14 -0
- package/dist/utils.js.map +1 -1
- package/package.json +4 -3
- package/src/frontmatter-link-validator.ts +28 -30
- package/src/frontmatter-validator.ts +19 -16
- package/src/html-link-parser.ts +140 -0
- package/src/html-transform.ts +157 -0
- package/src/index.ts +6 -1
- package/src/link-parser.ts +60 -11
- package/src/link-validator.ts +175 -145
- package/src/multi-schema-validator.ts +10 -8
- package/src/resource-registry.ts +205 -72
- package/src/schemas/link-auth.ts +146 -0
- package/src/schemas/project-config.ts +6 -0
- package/src/schemas/resource-metadata.ts +17 -1
- package/src/schemas/validation-result.ts +5 -29
- package/src/types/resources.ts +2 -1
- package/src/utils.ts +15 -0
package/src/resource-registry.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
|
300
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
|
672
|
-
const
|
|
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
|
-
//
|
|
681
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
710
|
-
|
|
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
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
|
947
|
+
* Determine the registry issue code based on the external-URL validation error.
|
|
852
948
|
* @private
|
|
853
949
|
*/
|
|
854
|
-
private
|
|
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 '
|
|
954
|
+
return 'EXTERNAL_URL_TIMEOUT';
|
|
859
955
|
}
|
|
860
|
-
return '
|
|
956
|
+
return 'EXTERNAL_URL_ERROR';
|
|
861
957
|
}
|
|
862
|
-
return '
|
|
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
|
|
1242
|
-
*
|
|
1243
|
-
*
|
|
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
|
|
1246
|
-
const map = new Map
|
|
1342
|
+
private buildFragmentIndex(): FragmentIndex {
|
|
1343
|
+
const map: FragmentIndex = new Map();
|
|
1247
1344
|
for (const resource of this.resourcesByPath.values()) {
|
|
1248
|
-
|
|
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
|
|
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
|
-
|
|
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
|