doc-freshness-checker 2.0.6 → 2.0.8
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 +4 -0
- package/dist/cli.test.js +10 -0
- package/dist/cli.test.js.map +1 -1
- package/dist/parsers/documentParser.js +31 -20
- package/dist/parsers/documentParser.js.map +1 -1
- package/dist/parsers/documentParser.test.js +60 -16
- package/dist/parsers/documentParser.test.js.map +1 -1
- package/dist/runner.js +35 -16
- package/dist/runner.js.map +1 -1
- package/dist/runner.test.js +498 -1
- package/dist/runner.test.js.map +1 -1
- package/dist/utils/incremental.d.ts +14 -18
- package/dist/utils/incremental.js +281 -39
- package/dist/utils/incremental.js.map +1 -1
- package/dist/utils/incremental.test.js +385 -74
- package/dist/utils/incremental.test.js.map +1 -1
- package/dist/utils/manifestPaths.d.ts +2 -0
- package/dist/utils/manifestPaths.js +6 -0
- package/dist/utils/manifestPaths.js.map +1 -0
- package/dist/validators/codePatternValidator.d.ts +3 -0
- package/dist/validators/codePatternValidator.js +31 -3
- package/dist/validators/codePatternValidator.js.map +1 -1
- package/dist/validators/codePatternValidator.test.js +64 -0
- package/dist/validators/codePatternValidator.test.js.map +1 -1
- package/dist/validators/dependencyValidator.js +9 -6
- package/dist/validators/dependencyValidator.js.map +1 -1
- package/dist/validators/dependencyValidator.test.js +6 -0
- package/dist/validators/dependencyValidator.test.js.map +1 -1
- package/dist/validators/directoryValidator.d.ts +1 -0
- package/dist/validators/directoryValidator.js +17 -4
- package/dist/validators/directoryValidator.js.map +1 -1
- package/dist/validators/directoryValidator.test.js +7 -0
- package/dist/validators/directoryValidator.test.js.map +1 -1
- package/dist/validators/fileValidator.d.ts +1 -0
- package/dist/validators/fileValidator.js +16 -14
- package/dist/validators/fileValidator.js.map +1 -1
- package/dist/validators/fileValidator.test.js +5 -0
- package/dist/validators/fileValidator.test.js.map +1 -1
- package/dist/validators/incrementalInputs.d.ts +1 -0
- package/dist/validators/incrementalInputs.js +6 -0
- package/dist/validators/incrementalInputs.js.map +1 -0
- package/dist/validators/validationEngine.d.ts +2 -0
- package/dist/validators/validationEngine.js +93 -14
- package/dist/validators/validationEngine.js.map +1 -1
- package/dist/validators/validationEngine.test.js +94 -4
- package/dist/validators/validationEngine.test.js.map +1 -1
- package/dist/validators/versionValidator.js +9 -6
- package/dist/validators/versionValidator.js.map +1 -1
- package/dist/validators/versionValidator.test.js +6 -0
- package/dist/validators/versionValidator.test.js.map +1 -1
- package/package.json +1 -1
package/dist/runner.test.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
2
3
|
import path from 'path';
|
|
4
|
+
import { glob } from 'glob';
|
|
3
5
|
import { run, runWithConfig } from './runner.js';
|
|
6
|
+
import { ValidationEngine } from './validators/validationEngine.js';
|
|
7
|
+
import { IncrementalChecker } from './utils/incremental.js';
|
|
8
|
+
import { FileValidator } from './validators/fileValidator.js';
|
|
4
9
|
import { withOutputFile } from './test-utils/tempFiles.js';
|
|
5
10
|
import { captureConsoleLog, captureConsoleWarn } from './test-utils/console.js';
|
|
6
11
|
vi.mock('fastembed', () => ({
|
|
@@ -33,6 +38,19 @@ describe('runner', () => {
|
|
|
33
38
|
];
|
|
34
39
|
const captureLog = captureConsoleLog;
|
|
35
40
|
const captureWarn = captureConsoleWarn;
|
|
41
|
+
const mockDocumentScan = (docPath) => {
|
|
42
|
+
vi.mocked(glob).mockResolvedValueOnce([docPath]);
|
|
43
|
+
};
|
|
44
|
+
const withIncrementalRoot = async (name, test) => {
|
|
45
|
+
const rootDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), `runner-inc-${name}-`));
|
|
46
|
+
try {
|
|
47
|
+
await test(rootDir);
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
vi.mocked(glob).mockResolvedValue([]);
|
|
51
|
+
await fs.promises.rm(rootDir, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
};
|
|
36
54
|
const baseConfig = {
|
|
37
55
|
rootDir: process.cwd(),
|
|
38
56
|
include: [],
|
|
@@ -55,6 +73,17 @@ describe('runner', () => {
|
|
|
55
73
|
reporters: [],
|
|
56
74
|
verbose: false,
|
|
57
75
|
};
|
|
76
|
+
const incrementalConfig = (rootDir, overrides = {}) => ({
|
|
77
|
+
...baseConfig,
|
|
78
|
+
rootDir,
|
|
79
|
+
include: ['*.md'],
|
|
80
|
+
sourcePatterns: [],
|
|
81
|
+
manifestFiles: [],
|
|
82
|
+
incremental: { enabled: true },
|
|
83
|
+
cache: { enabled: false, dir: '.cache' },
|
|
84
|
+
...overrides,
|
|
85
|
+
rules: { ...baseConfig.rules, ...overrides.rules },
|
|
86
|
+
});
|
|
58
87
|
afterAll(async () => {
|
|
59
88
|
await Promise.all(transientCacheDirs.map((dir) => fs.promises.rm(path.join(process.cwd(), dir), { recursive: true, force: true }).catch(() => { })));
|
|
60
89
|
});
|
|
@@ -95,6 +124,70 @@ describe('runner', () => {
|
|
|
95
124
|
});
|
|
96
125
|
expect(true).toBe(true);
|
|
97
126
|
});
|
|
127
|
+
it.each(['supportsFormat', 'extract'])('propagates custom extractor %s failures', async (hook) => {
|
|
128
|
+
vi.mocked(glob).mockResolvedValueOnce([path.join(process.cwd(), 'README.md')]);
|
|
129
|
+
const failure = () => {
|
|
130
|
+
throw new Error(`${hook} crashed`);
|
|
131
|
+
};
|
|
132
|
+
const extractor = {
|
|
133
|
+
supportsFormat: hook === 'supportsFormat' ? failure : () => true,
|
|
134
|
+
extract: hook === 'extract' ? failure : () => [],
|
|
135
|
+
};
|
|
136
|
+
await expect(run({
|
|
137
|
+
...baseConfig,
|
|
138
|
+
customExtractors: [extractor],
|
|
139
|
+
})).rejects.toThrow(`${hook} crashed`);
|
|
140
|
+
});
|
|
141
|
+
it('propagates custom validator execution failures', async () => {
|
|
142
|
+
const reference = {
|
|
143
|
+
type: 'custom',
|
|
144
|
+
value: 'value',
|
|
145
|
+
lineNumber: 1,
|
|
146
|
+
raw: 'value',
|
|
147
|
+
sourceFile: 'README.md',
|
|
148
|
+
};
|
|
149
|
+
vi.mocked(glob).mockResolvedValueOnce([path.join(process.cwd(), 'README.md')]);
|
|
150
|
+
await expect(run({
|
|
151
|
+
...baseConfig,
|
|
152
|
+
customExtractors: [{ extract: () => [reference], supportsFormat: () => true }],
|
|
153
|
+
customValidators: {
|
|
154
|
+
custom: { validateBatch: vi.fn().mockRejectedValue(new Error('custom validator crashed')) },
|
|
155
|
+
},
|
|
156
|
+
})).rejects.toThrow('custom validator crashed');
|
|
157
|
+
});
|
|
158
|
+
it('propagates built-in validator execution failures', async () => {
|
|
159
|
+
const reference = {
|
|
160
|
+
type: 'file-path',
|
|
161
|
+
value: './src/index.ts',
|
|
162
|
+
lineNumber: 1,
|
|
163
|
+
raw: './src/index.ts',
|
|
164
|
+
sourceFile: 'README.md',
|
|
165
|
+
};
|
|
166
|
+
vi.mocked(glob).mockResolvedValueOnce([path.join(process.cwd(), 'README.md')]);
|
|
167
|
+
const validateSpy = vi.spyOn(FileValidator.prototype, 'validateBatch').mockRejectedValueOnce(new Error('file validator crashed'));
|
|
168
|
+
try {
|
|
169
|
+
await expect(run({
|
|
170
|
+
...baseConfig,
|
|
171
|
+
rules: { ...baseConfig.rules, 'file-path': { enabled: true } },
|
|
172
|
+
customExtractors: [
|
|
173
|
+
{ extract: () => [reference], supportsFormat: () => true },
|
|
174
|
+
],
|
|
175
|
+
})).rejects.toThrow('file validator crashed');
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
validateSpy.mockRestore();
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
it('propagates matched-document read failures', async () => {
|
|
182
|
+
vi.mocked(glob).mockResolvedValueOnce([path.join(process.cwd(), 'README.md')]);
|
|
183
|
+
const readSpy = vi.spyOn(fs.promises, 'readFile').mockRejectedValueOnce(new Error('Permission denied'));
|
|
184
|
+
try {
|
|
185
|
+
await expect(run(baseConfig)).rejects.toThrow('Permission denied');
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
readSpy.mockRestore();
|
|
189
|
+
}
|
|
190
|
+
});
|
|
98
191
|
describe('verbose mode', () => {
|
|
99
192
|
it('logs config file path and source patterns', async () => {
|
|
100
193
|
const spy = captureLog();
|
|
@@ -245,7 +338,7 @@ describe('runner', () => {
|
|
|
245
338
|
});
|
|
246
339
|
});
|
|
247
340
|
describe('incremental mode', () => {
|
|
248
|
-
it('
|
|
341
|
+
it('reports changed files in verbose mode', async () => {
|
|
249
342
|
const spy = captureLog();
|
|
250
343
|
await run({
|
|
251
344
|
...baseConfig,
|
|
@@ -255,6 +348,410 @@ describe('runner', () => {
|
|
|
255
348
|
});
|
|
256
349
|
expect(spy.mock.calls.flat().join('\n')).toContain('Incremental');
|
|
257
350
|
});
|
|
351
|
+
it('skips a second clean run with default validator configuration', async () => {
|
|
352
|
+
await withIncrementalRoot('defaults', async (rootDir) => {
|
|
353
|
+
const docPath = path.join(rootDir, 'guide.md');
|
|
354
|
+
const targetPath = path.join(rootDir, 'target.ts');
|
|
355
|
+
await fs.promises.writeFile(docPath, '[target](target.ts)');
|
|
356
|
+
await fs.promises.writeFile(targetPath, 'export const target = true;');
|
|
357
|
+
const config = {
|
|
358
|
+
rootDir,
|
|
359
|
+
include: ['*.md'],
|
|
360
|
+
incremental: { enabled: true },
|
|
361
|
+
graph: { enabled: false },
|
|
362
|
+
cache: { enabled: false, dir: '.cache' },
|
|
363
|
+
reporters: [],
|
|
364
|
+
};
|
|
365
|
+
mockDocumentScan(docPath);
|
|
366
|
+
expect((await run(config)).summary.total).toBe(1);
|
|
367
|
+
mockDocumentScan(docPath);
|
|
368
|
+
expect((await run(config)).summary.total).toBe(0);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
it('allows reuse when git tracking is enabled without freshness scoring', async () => {
|
|
372
|
+
await withIncrementalRoot('git-only', async (rootDir) => {
|
|
373
|
+
const docPath = path.join(rootDir, 'guide.md');
|
|
374
|
+
const targetPath = path.join(rootDir, 'target.ts');
|
|
375
|
+
await fs.promises.writeFile(docPath, '[target](target.ts)');
|
|
376
|
+
await fs.promises.writeFile(targetPath, 'export const target = true;');
|
|
377
|
+
const config = incrementalConfig(rootDir, {
|
|
378
|
+
git: { enabled: true },
|
|
379
|
+
rules: { 'file-path': { enabled: true } },
|
|
380
|
+
});
|
|
381
|
+
mockDocumentScan(docPath);
|
|
382
|
+
expect((await run(config)).summary.total).toBe(1);
|
|
383
|
+
mockDocumentScan(docPath);
|
|
384
|
+
expect((await run(config)).summary.total).toBe(0);
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
it('reuses expected skips but revalidates when a disabled unsafe rule is enabled', async () => {
|
|
388
|
+
await withIncrementalRoot('disabled-rule', async (rootDir) => {
|
|
389
|
+
const docPath = path.join(rootDir, 'guide.md');
|
|
390
|
+
await fs.promises.writeFile(docPath, '[site](https://example.com)');
|
|
391
|
+
const config = incrementalConfig(rootDir, {
|
|
392
|
+
rules: { 'external-url': { enabled: false } },
|
|
393
|
+
});
|
|
394
|
+
mockDocumentScan(docPath);
|
|
395
|
+
expect((await run(config)).summary.skipped).toBe(1);
|
|
396
|
+
mockDocumentScan(docPath);
|
|
397
|
+
expect((await run(config)).summary.total).toBe(0);
|
|
398
|
+
mockDocumentScan(docPath);
|
|
399
|
+
expect((await run({ ...config, rules: { ...config.rules, 'external-url': { enabled: true } } })).summary.total).toBe(1);
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
it('reuses a clean run containing an illustrative safe-rule skip', async () => {
|
|
403
|
+
await withIncrementalRoot('illustrative-skip', async (rootDir) => {
|
|
404
|
+
const docPath = path.join(rootDir, 'guide.md');
|
|
405
|
+
await fs.promises.writeFile(docPath, '[example](YourProject/file.ts)');
|
|
406
|
+
const config = incrementalConfig(rootDir, {
|
|
407
|
+
rules: { 'file-path': { enabled: true, skipIllustrative: true } },
|
|
408
|
+
});
|
|
409
|
+
mockDocumentScan(docPath);
|
|
410
|
+
expect((await run(config)).summary.skipped).toBe(1);
|
|
411
|
+
mockDocumentScan(docPath);
|
|
412
|
+
expect((await run(config)).summary.total).toBe(0);
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
it('keeps state dirty when a validator is missing or throws', async () => {
|
|
416
|
+
await withIncrementalRoot('incomplete', async (rootDir) => {
|
|
417
|
+
const docPath = path.join(rootDir, 'guide.md');
|
|
418
|
+
const cacheDir = path.join(rootDir, '.cache');
|
|
419
|
+
await fs.promises.writeFile(docPath, 'unknown input');
|
|
420
|
+
const unknownExtractor = {
|
|
421
|
+
type: 'unknown-type',
|
|
422
|
+
supportedFormats: ['markdown'],
|
|
423
|
+
supportsFormat: () => true,
|
|
424
|
+
extract: (document) => [{ type: 'unknown-type', value: 'value', lineNumber: 1, raw: 'value', sourceFile: document.path }],
|
|
425
|
+
findLineNumber: () => 1,
|
|
426
|
+
getContext: () => '',
|
|
427
|
+
};
|
|
428
|
+
const missingConfig = incrementalConfig(rootDir, { customExtractors: [unknownExtractor] });
|
|
429
|
+
mockDocumentScan(docPath);
|
|
430
|
+
await run(missingConfig);
|
|
431
|
+
expect(JSON.parse(await fs.promises.readFile(path.join(cacheDir, 'file-hashes.json'), 'utf-8')).clean).toBe(false);
|
|
432
|
+
const throwingValidator = {
|
|
433
|
+
async validateBatch() {
|
|
434
|
+
throw new Error('validator failed');
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
const throwingConfig = incrementalConfig(rootDir, {
|
|
438
|
+
customValidators: { 'file-path': throwingValidator },
|
|
439
|
+
rules: { 'file-path': { enabled: true } },
|
|
440
|
+
});
|
|
441
|
+
await fs.promises.writeFile(docPath, '[target](target.ts)');
|
|
442
|
+
mockDocumentScan(docPath);
|
|
443
|
+
await expect(run(throwingConfig)).rejects.toThrow('validator failed');
|
|
444
|
+
expect(JSON.parse(await fs.promises.readFile(path.join(cacheDir, 'file-hashes.json'), 'utf-8')).clean).toBe(false);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
it('revalidates unchanged docs when a referenced source file is renamed', async () => {
|
|
448
|
+
await withIncrementalRoot('source', async (rootDir) => {
|
|
449
|
+
const docsDir = path.join(rootDir, 'docs');
|
|
450
|
+
const sourceDir = path.join(rootDir, 'src');
|
|
451
|
+
const docPath = path.join(docsDir, 'guide.md');
|
|
452
|
+
const sourcePath = path.join(sourceDir, 'server.ts');
|
|
453
|
+
const renamedSourcePath = path.join(sourceDir, 'renamed.ts');
|
|
454
|
+
const cacheDir = path.join(rootDir, '.cache');
|
|
455
|
+
await fs.promises.mkdir(docsDir, { recursive: true });
|
|
456
|
+
await fs.promises.mkdir(sourceDir, { recursive: true });
|
|
457
|
+
await fs.promises.writeFile(docPath, '[source](../src/server.ts)');
|
|
458
|
+
await fs.promises.writeFile(sourcePath, 'export const server = true;');
|
|
459
|
+
const config = incrementalConfig(rootDir, {
|
|
460
|
+
include: ['docs/**/*.md'],
|
|
461
|
+
sourcePatterns: ['src/**/*.ts'],
|
|
462
|
+
rules: { 'file-path': { enabled: true } },
|
|
463
|
+
});
|
|
464
|
+
mockDocumentScan(docPath);
|
|
465
|
+
expect((await run(config)).summary.errors).toBe(0);
|
|
466
|
+
await expect(fs.promises.access(path.join(cacheDir, 'file-hashes.json'))).resolves.toBeUndefined();
|
|
467
|
+
mockDocumentScan(docPath);
|
|
468
|
+
expect((await run(config)).summary.total).toBe(0);
|
|
469
|
+
await fs.promises.rename(sourcePath, renamedSourcePath);
|
|
470
|
+
mockDocumentScan(docPath);
|
|
471
|
+
expect((await run(config)).summary.errors).toBe(1);
|
|
472
|
+
const stateFile = path.join(cacheDir, 'file-hashes.json');
|
|
473
|
+
expect(JSON.parse(await fs.promises.readFile(stateFile, 'utf-8')).clean).toBe(false);
|
|
474
|
+
mockDocumentScan(docPath);
|
|
475
|
+
expect((await run(config)).summary.errors).toBe(1);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
it('reuses the code-pattern source index and fingerprints excluded source matches', async () => {
|
|
479
|
+
await withIncrementalRoot('code-pattern-source', async (rootDir) => {
|
|
480
|
+
const docPath = path.join(rootDir, 'guide.md');
|
|
481
|
+
const sourceDir = path.join(rootDir, '.git');
|
|
482
|
+
const sourcePath = path.join(sourceDir, 'source.ts');
|
|
483
|
+
await fs.promises.mkdir(sourceDir);
|
|
484
|
+
await fs.promises.writeFile(docPath, ['```typescript', 'class StableService {}', '```'].join('\n'));
|
|
485
|
+
await fs.promises.writeFile(sourcePath, 'export class StableService {}');
|
|
486
|
+
const config = incrementalConfig(rootDir, {
|
|
487
|
+
sourcePatterns: ['.g{it,noop}/**/*.ts'],
|
|
488
|
+
rules: { 'code-pattern': { enabled: true, severity: 'warning' } },
|
|
489
|
+
});
|
|
490
|
+
const mockScans = () => {
|
|
491
|
+
mockDocumentScan(docPath);
|
|
492
|
+
vi.mocked(glob).mockResolvedValueOnce([sourcePath]);
|
|
493
|
+
};
|
|
494
|
+
mockScans();
|
|
495
|
+
expect((await run(config)).summary.warnings).toBe(0);
|
|
496
|
+
mockScans();
|
|
497
|
+
expect((await run(config)).summary.total).toBe(0);
|
|
498
|
+
await fs.promises.writeFile(sourcePath, 'export class ReplacementService {}');
|
|
499
|
+
mockScans();
|
|
500
|
+
expect((await run(config)).summary.warnings).toBe(1);
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
it('captures graph-only source inputs once per run and skips unchanged validation', async () => {
|
|
504
|
+
await withIncrementalRoot('graph-source', async (rootDir) => {
|
|
505
|
+
const docPath = path.join(rootDir, 'guide.md');
|
|
506
|
+
const sourceDir = path.join(rootDir, 'src');
|
|
507
|
+
const sourcePath = path.join(sourceDir, 'source.ts');
|
|
508
|
+
await fs.promises.mkdir(sourceDir);
|
|
509
|
+
await fs.promises.writeFile(docPath, 'No code-pattern references');
|
|
510
|
+
await fs.promises.writeFile(sourcePath, 'export class GraphSource {}');
|
|
511
|
+
const config = incrementalConfig(rootDir, {
|
|
512
|
+
graph: { enabled: true },
|
|
513
|
+
sourcePatterns: ['src/**/*.ts'],
|
|
514
|
+
});
|
|
515
|
+
const mockScans = () => {
|
|
516
|
+
mockDocumentScan(docPath);
|
|
517
|
+
vi.mocked(glob).mockResolvedValueOnce([sourcePath]);
|
|
518
|
+
};
|
|
519
|
+
const validateSpy = vi.spyOn(ValidationEngine.prototype, 'validate');
|
|
520
|
+
const readSpy = vi.spyOn(fs.promises, 'readFile');
|
|
521
|
+
vi.mocked(glob).mockClear();
|
|
522
|
+
try {
|
|
523
|
+
mockScans();
|
|
524
|
+
await run(config);
|
|
525
|
+
mockScans();
|
|
526
|
+
await run(config);
|
|
527
|
+
expect(validateSpy.mock.calls[0][0]).toHaveLength(1);
|
|
528
|
+
expect(validateSpy.mock.calls[1][0]).toEqual([]);
|
|
529
|
+
expect(vi.mocked(glob).mock.calls.filter(([pattern]) => pattern === 'src/**/*.ts')).toHaveLength(2);
|
|
530
|
+
expect(readSpy.mock.calls.filter(([file]) => file === sourcePath)).toHaveLength(2);
|
|
531
|
+
}
|
|
532
|
+
finally {
|
|
533
|
+
validateSpy.mockRestore();
|
|
534
|
+
readSpy.mockRestore();
|
|
535
|
+
}
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
it('bypasses incremental inventory for an empty graph-enabled scan', async () => {
|
|
539
|
+
await withIncrementalRoot('empty-graph', async (rootDir) => {
|
|
540
|
+
const sourceDir = path.join(rootDir, 'src');
|
|
541
|
+
const sourcePath = path.join(sourceDir, 'source.ts');
|
|
542
|
+
await fs.promises.mkdir(sourceDir);
|
|
543
|
+
await fs.promises.writeFile(sourcePath, 'export class GraphSource {}');
|
|
544
|
+
const config = incrementalConfig(rootDir, {
|
|
545
|
+
graph: { enabled: true },
|
|
546
|
+
sourcePatterns: ['src/**/*.ts'],
|
|
547
|
+
});
|
|
548
|
+
const filterSpy = vi.spyOn(IncrementalChecker.prototype, 'filterChanged');
|
|
549
|
+
const readSpy = vi.spyOn(fs.promises, 'readFile');
|
|
550
|
+
vi.mocked(glob).mockClear();
|
|
551
|
+
try {
|
|
552
|
+
vi.mocked(glob).mockResolvedValueOnce([]).mockResolvedValueOnce([sourcePath]);
|
|
553
|
+
await run(config);
|
|
554
|
+
expect(filterSpy).not.toHaveBeenCalled();
|
|
555
|
+
await expect(fs.promises.access(path.join(rootDir, '.cache', 'file-hashes.json'))).rejects.toThrow();
|
|
556
|
+
expect(vi.mocked(glob).mock.calls.filter(([pattern]) => pattern === 'src/**/*.ts')).toHaveLength(1);
|
|
557
|
+
expect(readSpy.mock.calls.filter(([file]) => file === sourcePath)).toHaveLength(1);
|
|
558
|
+
}
|
|
559
|
+
finally {
|
|
560
|
+
filterSpy.mockRestore();
|
|
561
|
+
readSpy.mockRestore();
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
it('revalidates code-snippet fallbacks under excluded directories', async () => {
|
|
566
|
+
await withIncrementalRoot('snippet', async (rootDir) => {
|
|
567
|
+
const docsDir = path.join(rootDir, 'docs');
|
|
568
|
+
const dependencyDir = path.join(rootDir, 'node_modules');
|
|
569
|
+
const docPath = path.join(docsDir, 'guide.md');
|
|
570
|
+
const sourcePath = path.join(dependencyDir, 'generated.js');
|
|
571
|
+
await fs.promises.mkdir(docsDir);
|
|
572
|
+
await fs.promises.mkdir(dependencyDir);
|
|
573
|
+
await fs.promises.writeFile(docPath, "```js\nimport * as generated from '../node_modules/generated';\n```");
|
|
574
|
+
await fs.promises.writeFile(sourcePath, 'export const generated = true;');
|
|
575
|
+
const config = incrementalConfig(rootDir, {
|
|
576
|
+
include: ['docs/**/*.md'],
|
|
577
|
+
rules: { 'code-snippet': { enabled: true } },
|
|
578
|
+
});
|
|
579
|
+
mockDocumentScan(docPath);
|
|
580
|
+
expect((await run(config)).summary.warnings).toBe(0);
|
|
581
|
+
mockDocumentScan(docPath);
|
|
582
|
+
expect((await run(config)).summary.total).toBe(1);
|
|
583
|
+
await fs.promises.rm(sourcePath);
|
|
584
|
+
mockDocumentScan(docPath);
|
|
585
|
+
expect((await run(config)).summary.warnings).toBe(1);
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
it('revalidates unchanged docs after an unreported info finding', async () => {
|
|
589
|
+
await withIncrementalRoot('info', async (rootDir) => {
|
|
590
|
+
const docPath = path.join(rootDir, 'guide.md');
|
|
591
|
+
const cacheDir = path.join(rootDir, '.cache');
|
|
592
|
+
await fs.promises.writeFile(docPath, 'Install `missingpkg`.');
|
|
593
|
+
await fs.promises.writeFile(path.join(rootDir, 'package.json'), '{}');
|
|
594
|
+
const config = incrementalConfig(rootDir, {
|
|
595
|
+
manifestFiles: ['package.json'],
|
|
596
|
+
rules: { dependency: { enabled: true, severity: 'info' } },
|
|
597
|
+
cache: { enabled: false, dir: cacheDir },
|
|
598
|
+
});
|
|
599
|
+
mockDocumentScan(docPath);
|
|
600
|
+
expect((await run(config)).summary.total).toBe(1);
|
|
601
|
+
mockDocumentScan(docPath);
|
|
602
|
+
expect((await run(config)).summary.total).toBe(1);
|
|
603
|
+
expect(JSON.parse(await fs.promises.readFile(path.join(cacheDir, 'file-hashes.json'), 'utf-8')).clean).toBe(false);
|
|
604
|
+
});
|
|
605
|
+
});
|
|
606
|
+
it('revalidates after generated output changes the project inventory', async () => {
|
|
607
|
+
await withIncrementalRoot('output', async (rootDir) => {
|
|
608
|
+
const docPath = path.join(rootDir, 'guide.md');
|
|
609
|
+
const targetPath = path.join(rootDir, 'target.ts');
|
|
610
|
+
const outputPath = path.join(rootDir, 'report.json');
|
|
611
|
+
await fs.promises.writeFile(docPath, '[target](target.ts)');
|
|
612
|
+
await fs.promises.writeFile(targetPath, 'export const target = true;');
|
|
613
|
+
const config = incrementalConfig(rootDir, {
|
|
614
|
+
rules: { 'file-path': { enabled: true } },
|
|
615
|
+
reporters: ['json'],
|
|
616
|
+
outputPath,
|
|
617
|
+
});
|
|
618
|
+
mockDocumentScan(docPath);
|
|
619
|
+
expect((await run(config)).summary.total).toBe(1);
|
|
620
|
+
mockDocumentScan(docPath);
|
|
621
|
+
expect((await run(config)).summary.total).toBe(1);
|
|
622
|
+
mockDocumentScan(docPath);
|
|
623
|
+
expect((await run(config)).summary.total).toBe(0);
|
|
624
|
+
mockDocumentScan(docPath);
|
|
625
|
+
expect((await run(config)).summary.total).toBe(1);
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
it.each(['markdown', 'enhanced'])('skips unchanged docs when %s output is inside rootDir', async (reporter) => {
|
|
629
|
+
await withIncrementalRoot(`output-${reporter}`, async (rootDir) => {
|
|
630
|
+
const docPath = path.join(rootDir, 'guide.md');
|
|
631
|
+
const targetPath = path.join(rootDir, 'target.ts');
|
|
632
|
+
const outputPath = path.join(rootDir, `${reporter}.out`);
|
|
633
|
+
await fs.promises.writeFile(docPath, '[target](target.ts)');
|
|
634
|
+
await fs.promises.writeFile(targetPath, 'export const target = true;');
|
|
635
|
+
const config = incrementalConfig(rootDir, {
|
|
636
|
+
rules: { 'file-path': { enabled: true } },
|
|
637
|
+
reporters: [reporter],
|
|
638
|
+
outputPath,
|
|
639
|
+
});
|
|
640
|
+
mockDocumentScan(docPath);
|
|
641
|
+
expect((await run(config)).summary.total).toBe(1);
|
|
642
|
+
await expect(fs.promises.access(outputPath)).resolves.toBeUndefined();
|
|
643
|
+
mockDocumentScan(docPath);
|
|
644
|
+
expect((await run(config)).summary.total).toBe(0);
|
|
645
|
+
mockDocumentScan(docPath);
|
|
646
|
+
expect((await run(config)).summary.total).toBe(0);
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
it.each([
|
|
650
|
+
{ reporters: ['markdown', 'json'], expectedTotals: [1, 1, 0, 1] },
|
|
651
|
+
{ reporters: ['json', 'markdown'], expectedTotals: [1, 0, 0, 0] },
|
|
652
|
+
])('keeps mixed reporter ordering stable for incremental output', async ({ reporters, expectedTotals }) => {
|
|
653
|
+
await withIncrementalRoot(`mixed-output-${reporters.join('-')}`, async (rootDir) => {
|
|
654
|
+
const docPath = path.join(rootDir, 'guide.md');
|
|
655
|
+
const targetPath = path.join(rootDir, 'target.ts');
|
|
656
|
+
const outputPath = path.join(rootDir, 'report.out');
|
|
657
|
+
await fs.promises.writeFile(docPath, '[target](target.ts)');
|
|
658
|
+
await fs.promises.writeFile(targetPath, 'export const target = true;');
|
|
659
|
+
const config = incrementalConfig(rootDir, {
|
|
660
|
+
rules: { 'file-path': { enabled: true } },
|
|
661
|
+
reporters,
|
|
662
|
+
outputPath,
|
|
663
|
+
});
|
|
664
|
+
const totals = [];
|
|
665
|
+
for (let runNumber = 0; runNumber < expectedTotals.length; runNumber++) {
|
|
666
|
+
mockDocumentScan(docPath);
|
|
667
|
+
totals.push((await run(config)).summary.total);
|
|
668
|
+
}
|
|
669
|
+
expect(totals).toEqual(expectedTotals);
|
|
670
|
+
});
|
|
671
|
+
});
|
|
672
|
+
it('does not throw for markdown reporting without outputPath', async () => {
|
|
673
|
+
await withIncrementalRoot('markdown-stdout', async (rootDir) => {
|
|
674
|
+
const docPath = path.join(rootDir, 'guide.md');
|
|
675
|
+
await fs.promises.writeFile(docPath, 'No references');
|
|
676
|
+
const config = incrementalConfig(rootDir, { reporters: ['markdown'] });
|
|
677
|
+
const log = captureLog();
|
|
678
|
+
try {
|
|
679
|
+
mockDocumentScan(docPath);
|
|
680
|
+
await expect(run(config)).resolves.toBeDefined();
|
|
681
|
+
}
|
|
682
|
+
finally {
|
|
683
|
+
log.mockRestore();
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
it('revalidates when reporting mutates a validation input', async () => {
|
|
688
|
+
await withIncrementalRoot('output-overlap', async (rootDir) => {
|
|
689
|
+
const docPath = path.join(rootDir, 'guide.md');
|
|
690
|
+
const targetPath = path.join(rootDir, 'target.ts');
|
|
691
|
+
await fs.promises.writeFile(docPath, '[target](target.ts)');
|
|
692
|
+
await fs.promises.writeFile(targetPath, 'export const target = true;');
|
|
693
|
+
const config = incrementalConfig(rootDir, {
|
|
694
|
+
rules: { 'file-path': { enabled: true } },
|
|
695
|
+
reporters: ['json'],
|
|
696
|
+
outputPath: targetPath,
|
|
697
|
+
});
|
|
698
|
+
mockDocumentScan(docPath);
|
|
699
|
+
expect((await run(config)).summary.total).toBe(1);
|
|
700
|
+
mockDocumentScan(docPath);
|
|
701
|
+
expect((await run(config)).summary.total).toBe(1);
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
it('excludes lexical and real symlinked cache directories', async () => {
|
|
705
|
+
await withIncrementalRoot('cache-link', async (rootDir) => {
|
|
706
|
+
const docPath = path.join(rootDir, 'guide.md');
|
|
707
|
+
const targetPath = path.join(rootDir, 'target.ts');
|
|
708
|
+
const realCacheDir = path.join(rootDir, '.real-cache');
|
|
709
|
+
await fs.promises.writeFile(docPath, '[target](target.ts)');
|
|
710
|
+
await fs.promises.writeFile(targetPath, 'export const target = true;');
|
|
711
|
+
await fs.promises.mkdir(realCacheDir);
|
|
712
|
+
await fs.promises.symlink('.real-cache', path.join(rootDir, '.cache'), 'dir');
|
|
713
|
+
const config = incrementalConfig(rootDir, { rules: { 'file-path': { enabled: true } } });
|
|
714
|
+
mockDocumentScan(docPath);
|
|
715
|
+
expect((await run(config)).summary.total).toBe(1);
|
|
716
|
+
await expect(fs.promises.access(path.join(realCacheDir, 'file-hashes.json'))).resolves.toBeUndefined();
|
|
717
|
+
mockDocumentScan(docPath);
|
|
718
|
+
expect((await run(config)).summary.total).toBe(0);
|
|
719
|
+
});
|
|
720
|
+
});
|
|
721
|
+
it('does not advance state when validation or reporting throws', async () => {
|
|
722
|
+
await withIncrementalRoot('report', async (rootDir) => {
|
|
723
|
+
const docPath = path.join(rootDir, 'guide.md');
|
|
724
|
+
const cacheDir = path.join(rootDir, '.cache');
|
|
725
|
+
await fs.promises.writeFile(docPath, 'No references');
|
|
726
|
+
const config = incrementalConfig(rootDir, {
|
|
727
|
+
rules: { 'file-path': { enabled: true } },
|
|
728
|
+
cache: { enabled: false, dir: cacheDir },
|
|
729
|
+
});
|
|
730
|
+
mockDocumentScan(docPath);
|
|
731
|
+
await run(config);
|
|
732
|
+
const stateFile = path.join(cacheDir, 'file-hashes.json');
|
|
733
|
+
const baselineState = await fs.promises.readFile(stateFile, 'utf-8');
|
|
734
|
+
await fs.promises.writeFile(docPath, '[missing](missing.ts)');
|
|
735
|
+
const validateSpy = vi.spyOn(ValidationEngine.prototype, 'validate').mockRejectedValueOnce(new Error('validation failed'));
|
|
736
|
+
try {
|
|
737
|
+
mockDocumentScan(docPath);
|
|
738
|
+
await expect(run(config)).rejects.toThrow('validation failed');
|
|
739
|
+
expect(await fs.promises.readFile(stateFile, 'utf-8')).toBe(baselineState);
|
|
740
|
+
}
|
|
741
|
+
finally {
|
|
742
|
+
validateSpy.mockRestore();
|
|
743
|
+
}
|
|
744
|
+
mockDocumentScan(docPath);
|
|
745
|
+
await expect(run({
|
|
746
|
+
...config,
|
|
747
|
+
reporters: ['json'],
|
|
748
|
+
outputPath: rootDir,
|
|
749
|
+
})).rejects.toThrow();
|
|
750
|
+
expect(await fs.promises.readFile(stateFile, 'utf-8')).toBe(baselineState);
|
|
751
|
+
mockDocumentScan(docPath);
|
|
752
|
+
expect((await run(config)).summary.errors).toBe(1);
|
|
753
|
+
});
|
|
754
|
+
});
|
|
258
755
|
});
|
|
259
756
|
it('loads URL cache when cache is enabled', async () => {
|
|
260
757
|
const cacheDir = '.doc-freshness-cache/runner-url';
|