doc-freshness-checker 2.0.22 → 2.1.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 (107) hide show
  1. package/README.md +10 -3
  2. package/dist/cache/cacheManager.d.ts +20 -2
  3. package/dist/cache/cacheManager.js +137 -39
  4. package/dist/cache/cacheManager.js.map +1 -1
  5. package/dist/cache/cacheManager.test.js +132 -4
  6. package/dist/cache/cacheManager.test.js.map +1 -1
  7. package/dist/index.d.ts +6 -1
  8. package/dist/index.js +5 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/manifests/manifestInventory.d.ts +28 -0
  11. package/dist/manifests/manifestInventory.js +225 -0
  12. package/dist/manifests/manifestInventory.js.map +1 -0
  13. package/dist/manifests/manifestInventory.test.d.ts +1 -0
  14. package/dist/manifests/manifestInventory.test.js +176 -0
  15. package/dist/manifests/manifestInventory.test.js.map +1 -0
  16. package/dist/manifests/validatorConstructors.test.d.ts +1 -0
  17. package/dist/manifests/validatorConstructors.test.js +17 -0
  18. package/dist/manifests/validatorConstructors.test.js.map +1 -0
  19. package/dist/parsers/documentParser.d.ts +2 -3
  20. package/dist/parsers/documentParser.js.map +1 -1
  21. package/dist/parsers/extractorInterface.test.d.ts +1 -0
  22. package/dist/parsers/extractorInterface.test.js +34 -0
  23. package/dist/parsers/extractorInterface.test.js.map +1 -0
  24. package/dist/parsers/extractors/codeSnippetExtractor.js.map +1 -1
  25. package/dist/plugins/plugin.d.ts +5 -1
  26. package/dist/plugins/plugin.js +5 -1
  27. package/dist/plugins/plugin.js.map +1 -1
  28. package/dist/reporters/consoleReporter.d.ts +5 -11
  29. package/dist/reporters/consoleReporter.js +74 -76
  30. package/dist/reporters/consoleReporter.js.map +1 -1
  31. package/dist/reporters/consoleReporter.test.js +22 -27
  32. package/dist/reporters/consoleReporter.test.js.map +1 -1
  33. package/dist/reporters/enhancedReporter.d.ts +51 -7
  34. package/dist/reporters/enhancedReporter.js +128 -74
  35. package/dist/reporters/enhancedReporter.js.map +1 -1
  36. package/dist/reporters/enhancedReporter.test.js +110 -122
  37. package/dist/reporters/enhancedReporter.test.js.map +1 -1
  38. package/dist/reporters/jsonReporter.d.ts +6 -6
  39. package/dist/reporters/jsonReporter.js +24 -14
  40. package/dist/reporters/jsonReporter.js.map +1 -1
  41. package/dist/reporters/jsonReporter.test.js +26 -11
  42. package/dist/reporters/jsonReporter.test.js.map +1 -1
  43. package/dist/reporters/markdownIssueCells.d.ts +7 -0
  44. package/dist/reporters/markdownIssueCells.js +9 -0
  45. package/dist/reporters/markdownIssueCells.js.map +1 -0
  46. package/dist/reporters/markdownReporter.d.ts +2 -7
  47. package/dist/reporters/markdownReporter.js +57 -56
  48. package/dist/reporters/markdownReporter.js.map +1 -1
  49. package/dist/reporters/markdownReporter.test.js +104 -76
  50. package/dist/reporters/markdownReporter.test.js.map +1 -1
  51. package/dist/reporters/publicReporterApi.test.d.ts +1 -0
  52. package/dist/reporters/publicReporterApi.test.js +28 -0
  53. package/dist/reporters/publicReporterApi.test.js.map +1 -0
  54. package/dist/reporters/reportContext.d.ts +12 -0
  55. package/dist/reporters/reportContext.js +8 -0
  56. package/dist/reporters/reportContext.js.map +1 -0
  57. package/dist/reporters/reportContext.test.d.ts +1 -0
  58. package/dist/reporters/reportContext.test.js +30 -0
  59. package/dist/reporters/reportContext.test.js.map +1 -0
  60. package/dist/reporters/reporters.characterization.test.d.ts +1 -0
  61. package/dist/reporters/reporters.characterization.test.js +208 -0
  62. package/dist/reporters/reporters.characterization.test.js.map +1 -0
  63. package/dist/runner.js +88 -85
  64. package/dist/runner.js.map +1 -1
  65. package/dist/runner.test.js +545 -56
  66. package/dist/runner.test.js.map +1 -1
  67. package/dist/semantic/vectorSearch.d.ts +3 -3
  68. package/dist/semantic/vectorSearch.js +29 -30
  69. package/dist/semantic/vectorSearch.js.map +1 -1
  70. package/dist/semantic/vectorSearch.test.js +53 -4
  71. package/dist/semantic/vectorSearch.test.js.map +1 -1
  72. package/dist/source/sourceIndex.d.ts +39 -0
  73. package/dist/source/sourceIndex.js +444 -0
  74. package/dist/source/sourceIndex.js.map +1 -0
  75. package/dist/source/sourceIndex.largeGlob.test.d.ts +1 -0
  76. package/dist/source/sourceIndex.largeGlob.test.js +29 -0
  77. package/dist/source/sourceIndex.largeGlob.test.js.map +1 -0
  78. package/dist/source/sourceIndex.test.d.ts +1 -0
  79. package/dist/source/sourceIndex.test.js +263 -0
  80. package/dist/source/sourceIndex.test.js.map +1 -0
  81. package/dist/source/sourceValidators.d.ts +7 -0
  82. package/dist/source/sourceValidators.js +8 -0
  83. package/dist/source/sourceValidators.js.map +1 -0
  84. package/dist/source/sourceValidators.test.d.ts +1 -0
  85. package/dist/source/sourceValidators.test.js +17 -0
  86. package/dist/source/sourceValidators.test.js.map +1 -0
  87. package/dist/types.d.ts +12 -2
  88. package/dist/utils/incremental.d.ts +3 -1
  89. package/dist/utils/incremental.js +32 -8
  90. package/dist/utils/incremental.js.map +1 -1
  91. package/dist/utils/incremental.test.js +17 -0
  92. package/dist/utils/incremental.test.js.map +1 -1
  93. package/dist/validators/codePatternValidator.d.ts +5 -11
  94. package/dist/validators/codePatternValidator.js +17 -145
  95. package/dist/validators/codePatternValidator.js.map +1 -1
  96. package/dist/validators/codeSnippetValidator.d.ts +5 -18
  97. package/dist/validators/codeSnippetValidator.js +19 -312
  98. package/dist/validators/codeSnippetValidator.js.map +1 -1
  99. package/dist/validators/dependencyValidator.d.ts +0 -5
  100. package/dist/validators/dependencyValidator.js +7 -64
  101. package/dist/validators/dependencyValidator.js.map +1 -1
  102. package/dist/validators/versionValidator.d.ts +1 -10
  103. package/dist/validators/versionValidator.js +7 -157
  104. package/dist/validators/versionValidator.js.map +1 -1
  105. package/dist/validators/versionValidator.test.js +1 -101
  106. package/dist/validators/versionValidator.test.js.map +1 -1
  107. package/package.json +3 -3
@@ -4,10 +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';
15
+ import { CacheManager } from './cache/cacheManager.js';
16
+ import { FreshnessScorer } from './scoring/freshnessScorer.js';
11
17
  import { withOutputFile } from './test-utils/tempFiles.js';
12
18
  import { captureConsoleLog, captureConsoleWarn } from './test-utils/console.js';
13
19
  vi.mock('fastembed', () => ({
@@ -38,6 +44,9 @@ describe('runner', () => {
38
44
  '.doc-freshness-cache/runner-vs-nograph',
39
45
  '.doc-freshness-cache/runner-vs-reporters',
40
46
  '.doc-freshness-cache/rv-clear',
47
+ '.doc-freshness-cache/runner-clear-root',
48
+ '.doc-freshness-cache/runner-policy-root',
49
+ '.doc-freshness-cache/runner-incremental-disabled',
41
50
  ];
42
51
  const captureLog = captureConsoleLog;
43
52
  const captureWarn = captureConsoleWarn;
@@ -76,7 +85,7 @@ describe('runner', () => {
76
85
  sourcePatterns: [],
77
86
  manifestFiles: [],
78
87
  incremental: { enabled: true },
79
- cache: { enabled: false, dir: '.cache' },
88
+ cache: { enabled: true, dir: '.cache' },
80
89
  ...overrides,
81
90
  rules: { ...baseConfig.rules, ...overrides.rules },
82
91
  });
@@ -106,12 +115,14 @@ describe('runner', () => {
106
115
  expect(results.documents).toEqual([]);
107
116
  });
108
117
  it('clears cache when clearCache is set', async () => {
109
- const cacheDir = path.join(process.cwd(), '.doc-freshness-cache', 'runner-test');
118
+ const rootDir = path.join(cacheRoot, 'runner-clear-root');
119
+ const cacheDir = path.join(rootDir, '.result-cache');
110
120
  await fs.promises.mkdir(cacheDir, { recursive: true });
111
121
  await fs.promises.writeFile(path.join(cacheDir, 'dummy.json'), '{}');
112
122
  await run({
113
123
  ...baseConfig,
114
- cache: { enabled: true, dir: '.doc-freshness-cache/runner-test' },
124
+ rootDir,
125
+ cache: { enabled: false, dir: '.result-cache' },
115
126
  clearCache: true,
116
127
  });
117
128
  const exists = await fs.promises
@@ -120,15 +131,71 @@ describe('runner', () => {
120
131
  .catch(() => false);
121
132
  expect(exists).toBe(false);
122
133
  });
123
- it('registers custom extractors and validators', async () => {
124
- const extract = vi.fn().mockReturnValue([]);
125
- const validateBatch = vi.fn().mockResolvedValue([]);
126
- await run({
127
- ...baseConfig,
128
- customExtractors: [{ extract, supportsFormat: () => true }],
129
- customValidators: { custom: { validateBatch } },
134
+ it('ignores an unused outside cache directory when caching is disabled', async () => {
135
+ const outsideDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'doc-freshness-disabled-cache-'));
136
+ await expect(run({ ...baseConfig, cache: { enabled: false, dir: outsideDir } })).resolves.toBeDefined();
137
+ expect(await fs.promises.readdir(outsideDir)).toEqual([]);
138
+ await fs.promises.rm(outsideDir, { recursive: true, force: true });
139
+ });
140
+ it('continues when an enabled cache file is unreadable', async () => {
141
+ const cacheDir = '.doc-freshness-cache/runner-unreadable';
142
+ const readSpy = vi.spyOn(fs.promises, 'readFile').mockRejectedValueOnce(Object.assign(new Error('denied'), { code: 'EACCES' }));
143
+ await expect(run({ ...baseConfig, cache: { enabled: true, dir: cacheDir } })).resolves.toBeDefined();
144
+ readSpy.mockRestore();
145
+ await fs.promises.rm(path.join(process.cwd(), cacheDir), { recursive: true, force: true });
146
+ });
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
+ });
130
198
  });
131
- expect(true).toBe(true);
132
199
  });
133
200
  it.each(['supportsFormat', 'extract'])('propagates custom extractor %s failures', async (hook) => {
134
201
  vi.mocked(glob).mockResolvedValueOnce([path.join(process.cwd(), 'README.md')]);
@@ -175,9 +242,7 @@ describe('runner', () => {
175
242
  await expect(run({
176
243
  ...baseConfig,
177
244
  rules: { ...baseConfig.rules, 'file-path': { enabled: true } },
178
- customExtractors: [
179
- { extract: () => [reference], supportsFormat: () => true },
180
- ],
245
+ customExtractors: [{ extract: () => [reference], supportsFormat: () => true }],
181
246
  })).rejects.toThrow('file validator crashed');
182
247
  }
183
248
  finally {
@@ -218,6 +283,73 @@ describe('runner', () => {
218
283
  });
219
284
  });
220
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
+ });
221
353
  describe('verbose mode', () => {
222
354
  it('logs config file path and source patterns', async () => {
223
355
  const spy = captureLog();
@@ -252,9 +384,11 @@ describe('runner', () => {
252
384
  });
253
385
  });
254
386
  describe('reporters', () => {
255
- it('generates console report', async () => {
387
+ it('routes the Console emitter to stdout incrementally', async () => {
256
388
  const spy = captureLog();
257
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);
258
392
  expect(spy.mock.calls.flat().join('\n')).toContain('Documentation Freshness Report');
259
393
  });
260
394
  it('falls back to console when the reporter list is empty', async () => {
@@ -312,50 +446,255 @@ describe('runner', () => {
312
446
  expect(spy.mock.calls.flat().join('\n')).toContain('written to');
313
447
  });
314
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
+ });
315
538
  });
316
539
  describe('graph and scoring', () => {
317
- it('builds graph with git and saves cache', async () => {
540
+ it('builds graph without persisting an unused graph cache', async () => {
318
541
  captureLog();
319
542
  const cacheDir = '.doc-freshness-cache/runner-graph';
320
543
  try {
321
544
  await run({ ...baseConfig, graph: { enabled: true }, cache: { enabled: true, dir: cacheDir } });
322
- const exists = await fs.promises
323
- .access(path.join(process.cwd(), cacheDir, 'graph-cache.json'))
324
- .then(() => true)
325
- .catch(() => false);
326
- expect(exists).toBe(true);
545
+ await expect(fs.promises.access(path.join(process.cwd(), cacheDir, 'graph-cache.json'))).rejects.toThrow();
546
+ await expect(fs.promises.access(path.join(process.cwd(), cacheDir, 'url-cache.json'))).resolves.toBeUndefined();
327
547
  }
328
548
  finally {
329
549
  await fs.promises.rm(path.join(process.cwd(), cacheDir), { recursive: true, force: true }).catch(() => { });
330
550
  }
331
551
  });
332
- it('generates json with scores to file', async () => {
333
- 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 {
334
575
  captureLog();
335
- await run({
576
+ await expect(run({
336
577
  ...baseConfig,
337
578
  reporters: ['json'],
338
- outputPath,
339
579
  graph: { enabled: true },
340
580
  freshnessScoring: { enabled: true },
341
581
  cache: { enabled: false },
342
- });
343
- expect(JSON.parse(await fs.promises.readFile(outputPath, 'utf-8'))).toHaveProperty('summary');
344
- });
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
+ }
345
590
  });
346
- it('generates markdown with scores to file', async () => {
347
- await withOutputFile(cacheRoot, 'test-scored.md', async (outputPath) => {
348
- 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();
349
635
  await run({
350
636
  ...baseConfig,
351
637
  reporters: ['markdown'],
352
- outputPath,
353
638
  graph: { enabled: true },
354
639
  freshnessScoring: { enabled: true },
355
640
  cache: { enabled: false },
356
641
  });
357
- expect(await fs.promises.readFile(outputPath, 'utf-8')).toContain('Freshness Scores');
358
- });
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
+ }
359
698
  });
360
699
  it('generates enhanced with scores to file', async () => {
361
700
  await withOutputFile(cacheRoot, 'test-enhanced-scored.md', async (outputPath) => {
@@ -372,6 +711,50 @@ describe('runner', () => {
372
711
  });
373
712
  });
374
713
  });
714
+ describe('result cache policy', () => {
715
+ const modes = [false, true].flatMap((cache) => [false, true].flatMap((graph) => [false, true].flatMap((incremental) => [false, true].map((vector) => ({ cache, graph, incremental, vector })))));
716
+ it.each(modes)('applies cache=$cache graph=$graph incremental=$incremental vector=$vector under rootDir', async ({ cache, graph, incremental, vector }) => {
717
+ captureLog();
718
+ const rootDir = path.join(cacheRoot, 'runner-policy-root', `${Number(cache)}${Number(graph)}${Number(incremental)}${Number(vector)}`);
719
+ const resultDir = path.join(rootDir, '.result-cache');
720
+ const docPath = path.join(rootDir, 'README.md');
721
+ await fs.promises.mkdir(rootDir, { recursive: true });
722
+ await fs.promises.writeFile(docPath, '# Test');
723
+ vi.mocked(glob).mockResolvedValueOnce([docPath]);
724
+ await run({
725
+ ...baseConfig,
726
+ rootDir,
727
+ include: ['README.md'],
728
+ cache: { enabled: cache, dir: '.result-cache' },
729
+ graph: { enabled: graph },
730
+ incremental: { enabled: incremental },
731
+ vectorSearch: { enabled: vector },
732
+ });
733
+ const exists = (file) => fs.promises
734
+ .access(path.join(resultDir, file))
735
+ .then(() => true)
736
+ .catch(() => false);
737
+ expect(await exists('url-cache.json')).toBe(cache);
738
+ expect(await exists('file-hashes.json')).toBe(cache && incremental);
739
+ expect(await exists('embedding-cache.json')).toBe(cache && vector);
740
+ expect(await exists('graph-cache.json')).toBe(false);
741
+ if (!cache) {
742
+ await expect(fs.promises.access(resultDir)).rejects.toThrow();
743
+ }
744
+ });
745
+ it('continues when the URL cache cannot be saved', async () => {
746
+ const saveSpy = vi.spyOn(CacheManager.prototype, 'saveUrlCache').mockRejectedValueOnce(new Error('read only'));
747
+ const warnSpy = captureWarn();
748
+ try {
749
+ await expect(run({ ...baseConfig, cache: { enabled: true }, verbose: true })).resolves.toBeDefined();
750
+ expect(warnSpy).toHaveBeenCalledWith('Could not save the URL cache: read only');
751
+ }
752
+ finally {
753
+ saveSpy.mockRestore();
754
+ warnSpy.mockRestore();
755
+ }
756
+ });
757
+ });
375
758
  describe('incremental mode', () => {
376
759
  it('reports changed files in verbose mode', async () => {
377
760
  const spy = captureLog();
@@ -394,7 +777,7 @@ describe('runner', () => {
394
777
  include: ['*.md'],
395
778
  incremental: { enabled: true },
396
779
  graph: { enabled: false },
397
- cache: { enabled: false, dir: '.cache' },
780
+ cache: { enabled: true, dir: '.cache' },
398
781
  reporters: [],
399
782
  };
400
783
  mockDocumentScan(docPath);
@@ -629,7 +1012,7 @@ describe('runner', () => {
629
1012
  const config = incrementalConfig(rootDir, {
630
1013
  manifestFiles: ['package.json'],
631
1014
  rules: { dependency: { enabled: true, severity: 'info' } },
632
- cache: { enabled: false, dir: cacheDir },
1015
+ cache: { enabled: true, dir: cacheDir },
633
1016
  });
634
1017
  mockDocumentScan(docPath);
635
1018
  expect((await run(config)).summary.total).toBe(1);
@@ -760,7 +1143,7 @@ describe('runner', () => {
760
1143
  await fs.promises.writeFile(docPath, 'No references');
761
1144
  const config = incrementalConfig(rootDir, {
762
1145
  rules: { 'file-path': { enabled: true } },
763
- cache: { enabled: false, dir: cacheDir },
1146
+ cache: { enabled: true, dir: cacheDir },
764
1147
  });
765
1148
  mockDocumentScan(docPath);
766
1149
  await run(config);
@@ -787,14 +1170,41 @@ describe('runner', () => {
787
1170
  expect((await run(config)).summary.errors).toBe(1);
788
1171
  });
789
1172
  });
1173
+ it('checks every document without reading or saving state when caching is disabled', async () => {
1174
+ const rootDir = path.join(cacheRoot, 'runner-incremental-disabled');
1175
+ const docPath = path.join(rootDir, 'README.md');
1176
+ const stateFile = path.join(rootDir, '.result-cache', 'file-hashes.json');
1177
+ await fs.promises.mkdir(path.dirname(stateFile), { recursive: true });
1178
+ await fs.promises.writeFile(docPath, '# Test');
1179
+ await fs.promises.writeFile(stateFile, 'sentinel');
1180
+ vi.mocked(glob).mockResolvedValueOnce([docPath]);
1181
+ const readSpy = vi.spyOn(fs.promises, 'readFile');
1182
+ const spy = captureLog();
1183
+ await run({
1184
+ ...baseConfig,
1185
+ rootDir,
1186
+ include: ['README.md'],
1187
+ incremental: { enabled: true },
1188
+ cache: { enabled: false, dir: '.result-cache' },
1189
+ verbose: true,
1190
+ });
1191
+ expect(spy.mock.calls.flat().join('\n')).toContain('checking 1 changed files, skipping 0 unchanged');
1192
+ expect(readSpy).not.toHaveBeenCalledWith(stateFile, 'utf-8');
1193
+ expect(await fs.promises.readFile(stateFile, 'utf-8')).toBe('sentinel');
1194
+ readSpy.mockRestore();
1195
+ await fs.promises.rm(rootDir, { recursive: true, force: true });
1196
+ });
790
1197
  });
791
- it('loads URL cache when cache is enabled', async () => {
1198
+ it('loads and saves URL cache when graph is disabled', async () => {
792
1199
  const cacheDir = '.doc-freshness-cache/runner-url';
793
1200
  const fullDir = path.join(process.cwd(), cacheDir);
1201
+ const cacheFile = path.join(fullDir, 'url-cache.json');
1202
+ const cached = { 'https://cached.example': { result: { valid: true }, timestamp: Date.now() } };
794
1203
  await fs.promises.mkdir(fullDir, { recursive: true });
795
- await fs.promises.writeFile(path.join(fullDir, 'url-cache.json'), '{}');
1204
+ await fs.promises.writeFile(cacheFile, JSON.stringify(cached));
796
1205
  try {
797
1206
  expect(await run({ ...baseConfig, cache: { enabled: true, dir: cacheDir } })).toBeDefined();
1207
+ expect(await fs.promises.readFile(cacheFile, 'utf-8')).toBe(JSON.stringify(cached, null, 2));
798
1208
  }
799
1209
  finally {
800
1210
  await fs.promises.rm(fullDir, { recursive: true, force: true }).catch(() => { });
@@ -854,6 +1264,7 @@ describe('runner', () => {
854
1264
  const output = spy.mock.calls.flat().join('\n');
855
1265
  expect(output).toContain('Indexing documentation');
856
1266
  expect(output).toContain('Finding semantic mismatches');
1267
+ expect(output).not.toContain('Building source code index');
857
1268
  });
858
1269
  });
859
1270
  describe('runWithConfig', () => {
@@ -869,6 +1280,7 @@ describe('runner', () => {
869
1280
  });
870
1281
  });
871
1282
  describe('vector search without prior graph', () => {
1283
+ afterEach(() => vi.restoreAllMocks());
872
1284
  it('builds source index independently when graph not enabled', async () => {
873
1285
  const spy = captureLog();
874
1286
  await run({
@@ -881,36 +1293,113 @@ describe('runner', () => {
881
1293
  const output = spy.mock.calls.flat().join('\n');
882
1294
  expect(output).toContain('Building source code index');
883
1295
  });
884
- });
885
- describe('reporters with freshness scores', () => {
886
- 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
+ ]);
887
1307
  const spy = captureLog();
888
1308
  await run({
889
1309
  ...baseConfig,
890
- reporters: ['console'],
891
- graph: { enabled: true },
892
- freshnessScoring: { enabled: true },
893
- cache: { enabled: false },
1310
+ rules: { ...baseConfig.rules, 'code-pattern': { enabled: true } },
1311
+ vectorSearch: { enabled: true },
1312
+ graph: { enabled: false },
1313
+ verbose: true,
894
1314
  });
895
- expect(spy.mock.calls.flat().join('\n')).toContain('Freshness Scores');
1315
+ expect(spy.mock.calls.flat().join('\n')).not.toContain('Building source code index');
896
1316
  });
897
- it('json reporter generates with scores', async () => {
898
- 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');
899
1387
  await run({
900
1388
  ...baseConfig,
901
- reporters: ['json'],
902
1389
  graph: { enabled: true },
903
- freshnessScoring: { enabled: true },
904
- cache: { enabled: false },
1390
+ rules: { ...baseConfig.rules, 'code-pattern': { enabled: true } },
1391
+ customValidators: { 'code-pattern': { validateBatch } },
905
1392
  });
906
- const jsonStr = spy.mock.calls.flat().find((a) => typeof a === 'string' && a.startsWith('{'));
907
- expect(JSON.parse(jsonStr)).toHaveProperty('summary');
1393
+ expect(validateBatch).toHaveBeenCalledOnce();
1394
+ expect(load).toHaveBeenCalledOnce();
908
1395
  });
909
- 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 () => {
910
1399
  const spy = captureLog();
911
1400
  await run({
912
1401
  ...baseConfig,
913
- reporters: ['markdown'],
1402
+ reporters: ['console'],
914
1403
  graph: { enabled: true },
915
1404
  freshnessScoring: { enabled: true },
916
1405
  cache: { enabled: false },