doc-freshness-checker 2.0.23 → 2.2.0

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 (98) hide show
  1. package/README.md +19 -0
  2. package/dist/config/defaults.js +9 -3
  3. package/dist/config/defaults.js.map +1 -1
  4. package/dist/index.d.ts +6 -1
  5. package/dist/index.js +5 -0
  6. package/dist/index.js.map +1 -1
  7. package/dist/manifests/manifestInventory.d.ts +28 -0
  8. package/dist/manifests/manifestInventory.js +225 -0
  9. package/dist/manifests/manifestInventory.js.map +1 -0
  10. package/dist/manifests/manifestInventory.test.d.ts +1 -0
  11. package/dist/manifests/manifestInventory.test.js +176 -0
  12. package/dist/manifests/manifestInventory.test.js.map +1 -0
  13. package/dist/manifests/validatorConstructors.test.d.ts +1 -0
  14. package/dist/manifests/validatorConstructors.test.js +17 -0
  15. package/dist/manifests/validatorConstructors.test.js.map +1 -0
  16. package/dist/parsers/documentParser.d.ts +2 -3
  17. package/dist/parsers/documentParser.js.map +1 -1
  18. package/dist/parsers/extractorInterface.test.d.ts +1 -0
  19. package/dist/parsers/extractorInterface.test.js +34 -0
  20. package/dist/parsers/extractorInterface.test.js.map +1 -0
  21. package/dist/parsers/extractors/codeSnippetExtractor.js.map +1 -1
  22. package/dist/plugins/plugin.d.ts +5 -1
  23. package/dist/plugins/plugin.js +5 -1
  24. package/dist/plugins/plugin.js.map +1 -1
  25. package/dist/reporters/consoleReporter.d.ts +5 -11
  26. package/dist/reporters/consoleReporter.js +74 -76
  27. package/dist/reporters/consoleReporter.js.map +1 -1
  28. package/dist/reporters/consoleReporter.test.js +22 -27
  29. package/dist/reporters/consoleReporter.test.js.map +1 -1
  30. package/dist/reporters/enhancedReporter.d.ts +51 -7
  31. package/dist/reporters/enhancedReporter.js +128 -74
  32. package/dist/reporters/enhancedReporter.js.map +1 -1
  33. package/dist/reporters/enhancedReporter.test.js +110 -122
  34. package/dist/reporters/enhancedReporter.test.js.map +1 -1
  35. package/dist/reporters/jsonReporter.d.ts +6 -6
  36. package/dist/reporters/jsonReporter.js +24 -14
  37. package/dist/reporters/jsonReporter.js.map +1 -1
  38. package/dist/reporters/jsonReporter.test.js +26 -11
  39. package/dist/reporters/jsonReporter.test.js.map +1 -1
  40. package/dist/reporters/markdownIssueCells.d.ts +7 -0
  41. package/dist/reporters/markdownIssueCells.js +9 -0
  42. package/dist/reporters/markdownIssueCells.js.map +1 -0
  43. package/dist/reporters/markdownReporter.d.ts +2 -7
  44. package/dist/reporters/markdownReporter.js +57 -56
  45. package/dist/reporters/markdownReporter.js.map +1 -1
  46. package/dist/reporters/markdownReporter.test.js +104 -76
  47. package/dist/reporters/markdownReporter.test.js.map +1 -1
  48. package/dist/reporters/publicReporterApi.test.d.ts +1 -0
  49. package/dist/reporters/publicReporterApi.test.js +28 -0
  50. package/dist/reporters/publicReporterApi.test.js.map +1 -0
  51. package/dist/reporters/reportContext.d.ts +12 -0
  52. package/dist/reporters/reportContext.js +8 -0
  53. package/dist/reporters/reportContext.js.map +1 -0
  54. package/dist/reporters/reportContext.test.d.ts +1 -0
  55. package/dist/reporters/reportContext.test.js +30 -0
  56. package/dist/reporters/reportContext.test.js.map +1 -0
  57. package/dist/reporters/reporters.characterization.test.d.ts +1 -0
  58. package/dist/reporters/reporters.characterization.test.js +208 -0
  59. package/dist/reporters/reporters.characterization.test.js.map +1 -0
  60. package/dist/runner.js +67 -63
  61. package/dist/runner.js.map +1 -1
  62. package/dist/runner.test.js +444 -42
  63. package/dist/runner.test.js.map +1 -1
  64. package/dist/semantic/vectorSearch.js +2 -1
  65. package/dist/semantic/vectorSearch.js.map +1 -1
  66. package/dist/semantic/vectorSearch.test.js +2 -2
  67. package/dist/semantic/vectorSearch.test.js.map +1 -1
  68. package/dist/source/sourceIndex.d.ts +39 -0
  69. package/dist/source/sourceIndex.js +444 -0
  70. package/dist/source/sourceIndex.js.map +1 -0
  71. package/dist/source/sourceIndex.largeGlob.test.d.ts +1 -0
  72. package/dist/source/sourceIndex.largeGlob.test.js +29 -0
  73. package/dist/source/sourceIndex.largeGlob.test.js.map +1 -0
  74. package/dist/source/sourceIndex.test.d.ts +1 -0
  75. package/dist/source/sourceIndex.test.js +263 -0
  76. package/dist/source/sourceIndex.test.js.map +1 -0
  77. package/dist/source/sourceValidators.d.ts +7 -0
  78. package/dist/source/sourceValidators.js +8 -0
  79. package/dist/source/sourceValidators.js.map +1 -0
  80. package/dist/source/sourceValidators.test.d.ts +1 -0
  81. package/dist/source/sourceValidators.test.js +17 -0
  82. package/dist/source/sourceValidators.test.js.map +1 -0
  83. package/dist/types.d.ts +34 -2
  84. package/dist/validators/codePatternValidator.d.ts +5 -11
  85. package/dist/validators/codePatternValidator.js +17 -145
  86. package/dist/validators/codePatternValidator.js.map +1 -1
  87. package/dist/validators/codeSnippetValidator.d.ts +5 -18
  88. package/dist/validators/codeSnippetValidator.js +19 -312
  89. package/dist/validators/codeSnippetValidator.js.map +1 -1
  90. package/dist/validators/dependencyValidator.d.ts +0 -5
  91. package/dist/validators/dependencyValidator.js +7 -64
  92. package/dist/validators/dependencyValidator.js.map +1 -1
  93. package/dist/validators/versionValidator.d.ts +1 -10
  94. package/dist/validators/versionValidator.js +7 -157
  95. package/dist/validators/versionValidator.js.map +1 -1
  96. package/dist/validators/versionValidator.test.js +1 -101
  97. package/dist/validators/versionValidator.test.js.map +1 -1
  98. package/package.json +4 -4
@@ -4,11 +4,16 @@ import path from 'path';
4
4
  import { glob } from 'glob';
5
5
  import { run, runWithConfig } from './runner.js';
6
6
  import { BUILT_IN_RULE_TYPES } from './config/defaults.js';
7
+ import { GraphBuilder } from './graph/graphBuilder.js';
7
8
  import { VectorSearch } from './semantic/vectorSearch.js';
9
+ import { SourceIndex } from './source/sourceIndex.js';
10
+ import { DocumentParser } from './parsers/documentParser.js';
11
+ import { ManifestInventory } from './manifests/manifestInventory.js';
8
12
  import { ValidationEngine } from './validators/validationEngine.js';
9
13
  import { IncrementalChecker } from './utils/incremental.js';
10
14
  import { FileValidator } from './validators/fileValidator.js';
11
15
  import { CacheManager } from './cache/cacheManager.js';
16
+ import { FreshnessScorer } from './scoring/freshnessScorer.js';
12
17
  import { withOutputFile } from './test-utils/tempFiles.js';
13
18
  import { captureConsoleLog, captureConsoleWarn } from './test-utils/console.js';
14
19
  vi.mock('fastembed', () => ({
@@ -139,15 +144,58 @@ describe('runner', () => {
139
144
  readSpy.mockRestore();
140
145
  await fs.promises.rm(path.join(process.cwd(), cacheDir), { recursive: true, force: true });
141
146
  });
142
- it('registers custom extractors and validators', async () => {
143
- const extract = vi.fn().mockReturnValue([]);
144
- const validateBatch = vi.fn().mockResolvedValue([]);
145
- await run({
146
- ...baseConfig,
147
- customExtractors: [{ extract, supportsFormat: () => true }],
148
- customValidators: { custom: { validateBatch } },
147
+ it('round-trips a two-method custom extractor through an overriding custom validator', async () => {
148
+ await fs.promises.mkdir(cacheRoot, { recursive: true });
149
+ await withOutputFile(cacheRoot, 'custom-extractor.md', async (filePath) => {
150
+ const content = '# Custom\n\nprose';
151
+ await fs.promises.writeFile(filePath, content, 'utf-8');
152
+ vi.mocked(glob).mockResolvedValueOnce([filePath]);
153
+ const reference = {
154
+ type: 'file-path',
155
+ value: 'prose',
156
+ lineNumber: 3,
157
+ raw: 'prose',
158
+ sourceFile: 'custom-extractor.md',
159
+ };
160
+ const customExtractor = {
161
+ supportsFormat: vi.fn((format) => format === 'markdown'),
162
+ extract: vi.fn(() => [reference]),
163
+ };
164
+ const invalidResult = {
165
+ reference,
166
+ valid: false,
167
+ severity: 'error',
168
+ message: 'Custom reference is stale',
169
+ };
170
+ const customValidator = {
171
+ validateBatch: vi.fn(async () => [invalidResult]),
172
+ };
173
+ const config = {
174
+ ...baseConfig,
175
+ rootDir: cacheRoot,
176
+ include: ['**/*.md'],
177
+ rules: { ...baseConfig.rules, 'file-path': { enabled: true } },
178
+ customExtractors: [customExtractor],
179
+ customValidators: { 'file-path': customValidator },
180
+ };
181
+ const results = await run(config);
182
+ const document = {
183
+ path: 'custom-extractor.md',
184
+ absolutePath: filePath,
185
+ content,
186
+ format: 'markdown',
187
+ lines: ['# Custom', '', 'prose'],
188
+ references: [reference],
189
+ };
190
+ expect(vi.mocked(glob).mock.calls).toEqual([[['**/*.md'], { ignore: [], cwd: cacheRoot, absolute: true }]]);
191
+ expect(customExtractor.supportsFormat.mock.calls).toEqual([['markdown']]);
192
+ expect(customExtractor.extract.mock.calls).toEqual([[document]]);
193
+ expect(customValidator.validateBatch.mock.calls).toEqual([[[reference], document, config]]);
194
+ expect(results).toEqual({
195
+ documents: [{ path: 'custom-extractor.md', issues: [invalidResult] }],
196
+ summary: { total: 1, valid: 0, errors: 1, warnings: 0, info: 0, skipped: 0 },
197
+ });
149
198
  });
150
- expect(true).toBe(true);
151
199
  });
152
200
  it.each(['supportsFormat', 'extract'])('propagates custom extractor %s failures', async (hook) => {
153
201
  vi.mocked(glob).mockResolvedValueOnce([path.join(process.cwd(), 'README.md')]);
@@ -194,9 +242,7 @@ describe('runner', () => {
194
242
  await expect(run({
195
243
  ...baseConfig,
196
244
  rules: { ...baseConfig.rules, 'file-path': { enabled: true } },
197
- customExtractors: [
198
- { extract: () => [reference], supportsFormat: () => true },
199
- ],
245
+ customExtractors: [{ extract: () => [reference], supportsFormat: () => true }],
200
246
  })).rejects.toThrow('file validator crashed');
201
247
  }
202
248
  finally {
@@ -237,6 +283,73 @@ describe('runner', () => {
237
283
  });
238
284
  });
239
285
  });
286
+ describe('shared manifest inventory', () => {
287
+ afterEach(() => vi.restoreAllMocks());
288
+ const manifestDocument = {
289
+ path: 'doc.md',
290
+ absolutePath: '/doc.md',
291
+ content: '',
292
+ format: 'markdown',
293
+ lines: [],
294
+ references: [
295
+ {
296
+ type: 'version',
297
+ value: 'TypeScript 5.0',
298
+ technology: 'typescript',
299
+ version: '5.0',
300
+ lineNumber: 1,
301
+ raw: 'TypeScript 5.0',
302
+ sourceFile: 'doc.md',
303
+ },
304
+ { type: 'dependency', value: 'typescript', lineNumber: 2, raw: 'typescript', sourceFile: 'doc.md' },
305
+ ],
306
+ };
307
+ it('reads a manifest once for both built-in validators', async () => {
308
+ const rootDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'doc-freshness-runner-manifest-'));
309
+ await fs.promises.writeFile(path.join(rootDir, 'package.json'), JSON.stringify({ dependencies: { typescript: '5.9.0' } }));
310
+ vi.spyOn(DocumentParser.prototype, 'scanDocuments').mockResolvedValue([manifestDocument]);
311
+ const readFile = vi.spyOn(fs.promises, 'readFile');
312
+ try {
313
+ const results = await run({
314
+ ...baseConfig,
315
+ rootDir,
316
+ manifestFiles: ['package.json'],
317
+ rules: { ...baseConfig.rules, version: { enabled: true }, dependency: { enabled: true } },
318
+ });
319
+ expect(results.summary).toMatchObject({ total: 2, valid: 2 });
320
+ expect(readFile).toHaveBeenCalledOnce();
321
+ }
322
+ finally {
323
+ await fs.promises.rm(rootDir, { recursive: true, force: true });
324
+ }
325
+ });
326
+ it('does no manifest work when there are no references or both rules are disabled', async () => {
327
+ const dependencyNames = vi.spyOn(ManifestInventory.prototype, 'dependencyNames');
328
+ const packageVersions = vi.spyOn(ManifestInventory.prototype, 'packageVersions');
329
+ await run(baseConfig);
330
+ vi.spyOn(DocumentParser.prototype, 'scanDocuments').mockResolvedValue([manifestDocument]);
331
+ await run(baseConfig);
332
+ expect(dependencyNames).not.toHaveBeenCalled();
333
+ expect(packageVersions).not.toHaveBeenCalled();
334
+ });
335
+ it('does no built-in manifest work when custom validators replace both types', async () => {
336
+ vi.spyOn(DocumentParser.prototype, 'scanDocuments').mockResolvedValue([manifestDocument]);
337
+ const dependencyNames = vi.spyOn(ManifestInventory.prototype, 'dependencyNames');
338
+ const packageVersions = vi.spyOn(ManifestInventory.prototype, 'packageVersions');
339
+ const validateBatch = vi.fn().mockResolvedValue([]);
340
+ await run({
341
+ ...baseConfig,
342
+ rules: { ...baseConfig.rules, version: { enabled: true }, dependency: { enabled: true } },
343
+ customValidators: {
344
+ version: { validateBatch },
345
+ dependency: { validateBatch },
346
+ },
347
+ });
348
+ expect(validateBatch).toHaveBeenCalledTimes(2);
349
+ expect(dependencyNames).not.toHaveBeenCalled();
350
+ expect(packageVersions).not.toHaveBeenCalled();
351
+ });
352
+ });
240
353
  describe('verbose mode', () => {
241
354
  it('logs config file path and source patterns', async () => {
242
355
  const spy = captureLog();
@@ -271,9 +384,11 @@ describe('runner', () => {
271
384
  });
272
385
  });
273
386
  describe('reporters', () => {
274
- it('generates console report', async () => {
387
+ it('routes the Console emitter to stdout incrementally', async () => {
275
388
  const spy = captureLog();
276
389
  await run({ ...baseConfig, reporters: ['console'] });
390
+ expect(spy.mock.calls.length).toBeGreaterThan(1);
391
+ expect(spy.mock.calls.every((call) => call.length === 1)).toBe(true);
277
392
  expect(spy.mock.calls.flat().join('\n')).toContain('Documentation Freshness Report');
278
393
  });
279
394
  it('falls back to console when the reporter list is empty', async () => {
@@ -331,6 +446,95 @@ describe('runner', () => {
331
446
  expect(spy.mock.calls.flat().join('\n')).toContain('written to');
332
447
  });
333
448
  });
449
+ it('defaults an absent reporter list to Console', async () => {
450
+ const log = captureLog();
451
+ await run({ ...baseConfig, reporters: undefined });
452
+ expect(log.mock.calls.flat().join('\n')).toContain('Documentation Freshness Report');
453
+ });
454
+ it('preserves configured reporter order and duplicates', async () => {
455
+ const log = captureLog();
456
+ await run({ ...baseConfig, reporters: ['json', 'markdown', 'json'] });
457
+ const reports = log.mock.calls
458
+ .map(([value]) => value)
459
+ .filter((value) => typeof value === 'string' && (value.startsWith('{') || value.startsWith('#')));
460
+ expect(reports).toHaveLength(3);
461
+ expect(reports[0]).toMatch(/^\{/);
462
+ expect(reports[1]).toMatch(/^# Documentation Freshness Report/);
463
+ expect(reports[2]).toMatch(/^\{/);
464
+ });
465
+ it('treats inherited registry keys as unknown', async () => {
466
+ const inheritedKeys = ['toString', 'constructor', '__proto__'];
467
+ const log = captureLog();
468
+ const warn = captureWarn();
469
+ await expect(run({ ...baseConfig, reporters: inheritedKeys })).resolves.toBeDefined();
470
+ expect(warn.mock.calls).toEqual([
471
+ ['Unknown reporter type: toString'],
472
+ ['Unknown reporter type: constructor'],
473
+ ['Unknown reporter type: __proto__'],
474
+ ]);
475
+ expect(log.mock.calls.some(([value]) => typeof value === 'string' && value.startsWith('{'))).toBe(false);
476
+ const output = log.mock.calls.flat().join('\n');
477
+ expect(output).not.toContain('Documentation Freshness Report');
478
+ expect(output).not.toContain('Documentation Freshness Scan Report');
479
+ });
480
+ it('keeps Console on stdout and ignores outputPath', async () => {
481
+ await withOutputFile(cacheRoot, 'console-must-not-write.out', async (outputPath) => {
482
+ const log = captureLog();
483
+ await run({ ...baseConfig, reporters: ['console'], outputPath });
484
+ expect(log.mock.calls.flat().join('\n')).toContain('Documentation Freshness Report');
485
+ await expect(fs.promises.access(outputPath)).rejects.toThrow();
486
+ });
487
+ });
488
+ it('overwrites a shared outputPath sequentially so the last string reporter wins', async () => {
489
+ await withOutputFile(cacheRoot, 'shared-reporter.out', async (outputPath) => {
490
+ captureLog();
491
+ await run({ ...baseConfig, reporters: ['json', 'markdown'], outputPath });
492
+ expect(await fs.promises.readFile(outputPath, 'utf-8')).toMatch(/^# Documentation Freshness Report/);
493
+ await run({ ...baseConfig, reporters: ['markdown', 'json'], outputPath });
494
+ expect(await fs.promises.readFile(outputPath, 'utf-8')).toMatch(/^\{/);
495
+ });
496
+ });
497
+ it('creates recursive output directories and reports each verbose label', async () => {
498
+ const outputRoot = path.join(cacheRoot, 'reporter-routing');
499
+ const outputPath = path.join(outputRoot, 'nested', 'report.out');
500
+ const log = captureLog();
501
+ try {
502
+ await run({ ...baseConfig, reporters: ['json', 'markdown'], outputPath, verbose: true });
503
+ expect(await fs.promises.readFile(outputPath, 'utf-8')).toMatch(/^# Documentation Freshness Report/);
504
+ expect(log.mock.calls.flat()).toContain(`JSON report written to ${outputPath}`);
505
+ expect(log.mock.calls.flat()).toContain(`Markdown report written to ${outputPath}`);
506
+ }
507
+ finally {
508
+ await fs.promises.rm(outputRoot, { recursive: true, force: true });
509
+ }
510
+ });
511
+ it('rejects when writing a string report fails', async () => {
512
+ const outputPath = path.join(cacheRoot, 'reporter-write-directory');
513
+ await fs.promises.mkdir(outputPath, { recursive: true });
514
+ try {
515
+ captureLog();
516
+ await expect(run({ ...baseConfig, reporters: ['json'], outputPath })).rejects.toThrow();
517
+ }
518
+ finally {
519
+ await fs.promises.rm(outputPath, { recursive: true, force: true });
520
+ }
521
+ });
522
+ it('reads the clock only for timestamped reporter paths and propagates clock failures', async () => {
523
+ const toISOString = vi.spyOn(Date.prototype, 'toISOString').mockImplementation(() => {
524
+ throw new Error('timestamp failed');
525
+ });
526
+ const log = captureLog();
527
+ try {
528
+ await expect(run({ ...baseConfig, reporters: ['console'] })).resolves.toBeDefined();
529
+ await expect(run({ ...baseConfig, reporters: ['json'] })).resolves.toBeDefined();
530
+ expect(toISOString).not.toHaveBeenCalled();
531
+ expect(log).toHaveBeenCalled();
532
+ await expect(run({ ...baseConfig, reporters: ['markdown'] })).rejects.toThrow('timestamp failed');
533
+ }
534
+ finally {
535
+ toISOString.mockRestore();
536
+ }
537
+ });
334
538
  });
335
539
  describe('graph and scoring', () => {
336
540
  it('builds graph without persisting an unused graph cache', async () => {
@@ -345,33 +549,152 @@ describe('runner', () => {
345
549
  await fs.promises.rm(path.join(process.cwd(), cacheDir), { recursive: true, force: true }).catch(() => { });
346
550
  }
347
551
  });
348
- it('generates json with scores to file', async () => {
349
- await withOutputFile(cacheRoot, 'test-scored.json', async (outputPath) => {
552
+ it('does not read the clock when runner scored-result spreading fails', async () => {
553
+ const validationResults = {
554
+ documents: [],
555
+ summary: { total: 0, valid: 0, errors: 0, warnings: 0, skipped: 0 },
556
+ };
557
+ const proxiedResults = new Proxy(validationResults, {
558
+ get(target, property, receiver) {
559
+ if (property === 'documents') {
560
+ throw new Error('runner result getter failed');
561
+ }
562
+ return Reflect.get(target, property, receiver);
563
+ },
564
+ });
565
+ const scores = {
566
+ projectScore: 100,
567
+ projectGrade: 'A',
568
+ documents: [],
569
+ summary: { total: 0, gradeA: 0, gradeB: 0, gradeC: 0, gradeD: 0, gradeF: 0 },
570
+ };
571
+ const validate = vi.spyOn(ValidationEngine.prototype, 'validate').mockResolvedValue(proxiedResults);
572
+ const calculateScores = vi.spyOn(FreshnessScorer.prototype, 'calculateProjectScores').mockReturnValue(scores);
573
+ const clock = vi.spyOn(Date.prototype, 'toISOString');
574
+ try {
350
575
  captureLog();
351
- await run({
576
+ await expect(run({
352
577
  ...baseConfig,
353
578
  reporters: ['json'],
354
- outputPath,
355
579
  graph: { enabled: true },
356
580
  freshnessScoring: { enabled: true },
357
581
  cache: { enabled: false },
358
- });
359
- expect(JSON.parse(await fs.promises.readFile(outputPath, 'utf-8'))).toHaveProperty('summary');
360
- });
582
+ })).rejects.toThrow('runner result getter failed');
583
+ expect(clock).not.toHaveBeenCalled();
584
+ }
585
+ finally {
586
+ validate.mockRestore();
587
+ calculateScores.mockRestore();
588
+ clock.mockRestore();
589
+ }
361
590
  });
362
- it('generates markdown with scores to file', async () => {
363
- await withOutputFile(cacheRoot, 'test-scored.md', async (outputPath) => {
364
- captureLog();
591
+ it('captures Markdown result references before the clock, then renders the base before scores', async () => {
592
+ const events = [];
593
+ const summary = { total: 1, valid: 1, errors: 0, warnings: 0, skipped: 0 };
594
+ const documents = [];
595
+ let currentSummary = summary;
596
+ let currentDocuments = documents;
597
+ const validationResults = {
598
+ get summary() {
599
+ events.push('results:summary');
600
+ return currentSummary;
601
+ },
602
+ set summary(value) {
603
+ currentSummary = value;
604
+ },
605
+ get documents() {
606
+ events.push('results:documents');
607
+ return currentDocuments;
608
+ },
609
+ set documents(value) {
610
+ currentDocuments = value;
611
+ },
612
+ };
613
+ const scores = {
614
+ get projectScore() {
615
+ events.push('scores:projectScore');
616
+ summary.total = 99;
617
+ return 100;
618
+ },
619
+ projectGrade: 'A',
620
+ documents: [],
621
+ summary: { total: 0, gradeA: 0, gradeB: 0, gradeC: 0, gradeD: 0, gradeF: 0 },
622
+ };
623
+ const validate = vi.spyOn(ValidationEngine.prototype, 'validate').mockResolvedValue(validationResults);
624
+ const calculateScores = vi.spyOn(FreshnessScorer.prototype, 'calculateProjectScores').mockReturnValue(scores);
625
+ const clock = vi.spyOn(Date.prototype, 'toISOString').mockImplementation(() => {
626
+ events.push('clock');
627
+ summary.total = 7;
628
+ documents.push({ path: 'docs/captured.md', issues: [] });
629
+ validationResults.summary = { total: 50, valid: 50, errors: 0, warnings: 0, skipped: 0 };
630
+ validationResults.documents = [];
631
+ return '2025-01-02T03:04:05.678Z';
632
+ });
633
+ try {
634
+ const log = captureLog();
365
635
  await run({
366
636
  ...baseConfig,
367
637
  reporters: ['markdown'],
368
- outputPath,
369
638
  graph: { enabled: true },
370
639
  freshnessScoring: { enabled: true },
371
640
  cache: { enabled: false },
372
641
  });
373
- expect(await fs.promises.readFile(outputPath, 'utf-8')).toContain('Freshness Scores');
374
- });
642
+ const report = log.mock.calls.flat().find((value) => typeof value === 'string' && value.startsWith('#'));
643
+ expect(events).toEqual(['results:summary', 'results:documents', 'clock', 'scores:projectScore']);
644
+ expect(report).toContain('| Total Checked | 7 |');
645
+ expect(report).toContain('docs/captured.md');
646
+ expect(report).not.toContain('| Total Checked | 99 |');
647
+ expect(report).not.toContain('| Total Checked | 50 |');
648
+ }
649
+ finally {
650
+ validate.mockRestore();
651
+ calculateScores.mockRestore();
652
+ clock.mockRestore();
653
+ }
654
+ });
655
+ it('does not inspect Markdown documents, clock, or scores when the summary getter fails', async () => {
656
+ const events = [];
657
+ let scoreRead = false;
658
+ const validationResults = {
659
+ get summary() {
660
+ events.push('results:summary');
661
+ throw new Error('Markdown summary failed');
662
+ },
663
+ get documents() {
664
+ events.push('results:documents');
665
+ return [];
666
+ },
667
+ };
668
+ const scores = {
669
+ get projectScore() {
670
+ scoreRead = true;
671
+ return 100;
672
+ },
673
+ projectGrade: 'A',
674
+ documents: [],
675
+ summary: { total: 0, gradeA: 0, gradeB: 0, gradeC: 0, gradeD: 0, gradeF: 0 },
676
+ };
677
+ const validate = vi.spyOn(ValidationEngine.prototype, 'validate').mockResolvedValue(validationResults);
678
+ const calculateScores = vi.spyOn(FreshnessScorer.prototype, 'calculateProjectScores').mockReturnValue(scores);
679
+ const clock = vi.spyOn(Date.prototype, 'toISOString');
680
+ try {
681
+ captureLog();
682
+ await expect(run({
683
+ ...baseConfig,
684
+ reporters: ['markdown'],
685
+ graph: { enabled: true },
686
+ freshnessScoring: { enabled: true },
687
+ cache: { enabled: false },
688
+ })).rejects.toThrow('Markdown summary failed');
689
+ expect(events).toEqual(['results:summary']);
690
+ expect(clock).not.toHaveBeenCalled();
691
+ expect(scoreRead).toBe(false);
692
+ }
693
+ finally {
694
+ validate.mockRestore();
695
+ calculateScores.mockRestore();
696
+ clock.mockRestore();
697
+ }
375
698
  });
376
699
  it('generates enhanced with scores to file', async () => {
377
700
  await withOutputFile(cacheRoot, 'test-enhanced-scored.md', async (outputPath) => {
@@ -941,6 +1264,7 @@ describe('runner', () => {
941
1264
  const output = spy.mock.calls.flat().join('\n');
942
1265
  expect(output).toContain('Indexing documentation');
943
1266
  expect(output).toContain('Finding semantic mismatches');
1267
+ expect(output).not.toContain('Building source code index');
944
1268
  });
945
1269
  });
946
1270
  describe('runWithConfig', () => {
@@ -956,6 +1280,7 @@ describe('runner', () => {
956
1280
  });
957
1281
  });
958
1282
  describe('vector search without prior graph', () => {
1283
+ afterEach(() => vi.restoreAllMocks());
959
1284
  it('builds source index independently when graph not enabled', async () => {
960
1285
  const spy = captureLog();
961
1286
  await run({
@@ -968,36 +1293,113 @@ describe('runner', () => {
968
1293
  const output = spy.mock.calls.flat().join('\n');
969
1294
  expect(output).toContain('Building source code index');
970
1295
  });
971
- });
972
- describe('reporters with freshness scores', () => {
973
- it('console reporter uses generateWithScores when scores available', async () => {
1296
+ it('does not report index building when code-pattern validation already loaded it', async () => {
1297
+ vi.spyOn(DocumentParser.prototype, 'scanDocuments').mockResolvedValue([
1298
+ {
1299
+ path: 'doc.md',
1300
+ absolutePath: '/doc.md',
1301
+ content: '',
1302
+ format: 'markdown',
1303
+ lines: [],
1304
+ references: [{ type: 'code-pattern', value: 'RealSymbol', lineNumber: 1, raw: 'RealSymbol', sourceFile: 'doc.md' }],
1305
+ },
1306
+ ]);
974
1307
  const spy = captureLog();
975
1308
  await run({
976
1309
  ...baseConfig,
977
- reporters: ['console'],
978
- graph: { enabled: true },
979
- freshnessScoring: { enabled: true },
980
- cache: { enabled: false },
1310
+ rules: { ...baseConfig.rules, 'code-pattern': { enabled: true } },
1311
+ vectorSearch: { enabled: true },
1312
+ graph: { enabled: false },
1313
+ verbose: true,
981
1314
  });
982
- expect(spy.mock.calls.flat().join('\n')).toContain('Freshness Scores');
1315
+ expect(spy.mock.calls.flat().join('\n')).not.toContain('Building source code index');
983
1316
  });
984
- it('json reporter generates with scores', async () => {
985
- const spy = captureLog();
1317
+ });
1318
+ describe('shared source index', () => {
1319
+ afterEach(() => vi.restoreAllMocks());
1320
+ it('does not load source when no validator, graph, or vector consumer needs it', async () => {
1321
+ const load = vi.spyOn(SourceIndex.prototype, 'load');
1322
+ await run(baseConfig);
1323
+ expect(load).not.toHaveBeenCalled();
1324
+ });
1325
+ it('uses one index instance for both built-in validators', async () => {
1326
+ vi.spyOn(DocumentParser.prototype, 'scanDocuments').mockResolvedValue([
1327
+ {
1328
+ path: 'doc.md',
1329
+ absolutePath: '/doc.md',
1330
+ content: '',
1331
+ format: 'markdown',
1332
+ lines: [],
1333
+ references: [
1334
+ { type: 'code-pattern', value: 'RealSymbol', lineNumber: 1, raw: 'RealSymbol', sourceFile: 'doc.md' },
1335
+ { type: 'code-snippet', kind: 'function-call', value: 'realFunction', lineNumber: 2, raw: '', sourceFile: 'doc.md' },
1336
+ ],
1337
+ },
1338
+ ]);
1339
+ const load = vi.spyOn(SourceIndex.prototype, 'load');
1340
+ await run({
1341
+ ...baseConfig,
1342
+ rules: {
1343
+ ...baseConfig.rules,
1344
+ 'code-pattern': { enabled: true },
1345
+ 'code-snippet': { enabled: true },
1346
+ },
1347
+ });
1348
+ expect(load).toHaveBeenCalledTimes(2);
1349
+ expect(new Set(load.mock.contexts).size).toBe(1);
1350
+ });
1351
+ it('feeds symbols to graph and all pattern files to vector search', async () => {
1352
+ const symbols = new Map();
1353
+ const patternFiles = new Map([['src/no-symbol.ts', { content: '// a useful comment', language: 'typescript' }]]);
1354
+ const snapshot = {
1355
+ symbols,
1356
+ patternFiles,
1357
+ snippetFiles: new Map(),
1358
+ functionSignatures: new Map(),
1359
+ interfaceKeys: new Map(),
1360
+ exportsByFile: new Map(),
1361
+ patternInputs: [],
1362
+ };
1363
+ const load = vi.spyOn(SourceIndex.prototype, 'load').mockResolvedValue(snapshot);
1364
+ const buildGraph = vi.spyOn(GraphBuilder.prototype, 'buildGraph');
1365
+ const indexCodeComments = vi.spyOn(VectorSearch.prototype, 'indexCodeComments');
1366
+ captureLog();
1367
+ await run({ ...baseConfig, graph: { enabled: true }, vectorSearch: { enabled: true } });
1368
+ expect(buildGraph).toHaveBeenCalledWith([], symbols);
1369
+ expect(indexCodeComments).toHaveBeenCalledWith([
1370
+ { path: 'src/no-symbol.ts', content: '// a useful comment', language: 'typescript' },
1371
+ ]);
1372
+ expect(new Set(load.mock.contexts).size).toBe(1);
1373
+ });
1374
+ it('keeps custom validator overwrite independent from graph source loading', async () => {
1375
+ const validateBatch = vi.fn().mockResolvedValue([]);
1376
+ vi.spyOn(DocumentParser.prototype, 'scanDocuments').mockResolvedValue([
1377
+ {
1378
+ path: 'doc.md',
1379
+ absolutePath: '/doc.md',
1380
+ content: '',
1381
+ format: 'markdown',
1382
+ lines: [],
1383
+ references: [{ type: 'code-pattern', value: 'Custom', lineNumber: 1, raw: 'Custom', sourceFile: 'doc.md' }],
1384
+ },
1385
+ ]);
1386
+ const load = vi.spyOn(SourceIndex.prototype, 'load');
986
1387
  await run({
987
1388
  ...baseConfig,
988
- reporters: ['json'],
989
1389
  graph: { enabled: true },
990
- freshnessScoring: { enabled: true },
991
- cache: { enabled: false },
1390
+ rules: { ...baseConfig.rules, 'code-pattern': { enabled: true } },
1391
+ customValidators: { 'code-pattern': { validateBatch } },
992
1392
  });
993
- const jsonStr = spy.mock.calls.flat().find((a) => typeof a === 'string' && a.startsWith('{'));
994
- expect(JSON.parse(jsonStr)).toHaveProperty('summary');
1393
+ expect(validateBatch).toHaveBeenCalledOnce();
1394
+ expect(load).toHaveBeenCalledOnce();
995
1395
  });
996
- it('markdown reporter generates with scores to stdout', async () => {
1396
+ });
1397
+ describe('reporters with freshness scores', () => {
1398
+ it('console reporter uses generateWithScores when scores available', async () => {
997
1399
  const spy = captureLog();
998
1400
  await run({
999
1401
  ...baseConfig,
1000
- reporters: ['markdown'],
1402
+ reporters: ['console'],
1001
1403
  graph: { enabled: true },
1002
1404
  freshnessScoring: { enabled: true },
1003
1405
  cache: { enabled: false },