@rigour-labs/core 6.6.1 → 6.6.3
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/dist/deep/diff-tests/run.js +2 -12
- package/dist/firewall/firewall.test.js +4 -0
- package/dist/firewall/trusted-control.js +2 -2
- package/dist/firewall/typed-command.js +2 -1
- package/dist/gates/logic-drift-git-base.d.ts +9 -0
- package/dist/gates/logic-drift-git-base.js +16 -11
- package/dist/gates/runner.d.ts +3 -0
- package/dist/gates/runner.js +4 -1
- package/dist/hooks/index.d.ts +2 -2
- package/dist/hooks/index.js +2 -2
- package/dist/hooks/session-state.d.ts +9 -0
- package/dist/hooks/session-state.js +33 -0
- package/dist/hooks/stop-review.d.ts +18 -11
- package/dist/hooks/stop-review.js +24 -8
- package/dist/hooks/templates.d.ts +8 -1
- package/dist/hooks/templates.js +19 -2
- package/dist/hooks/templates.test.js +12 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/review/baseline.d.ts +16 -0
- package/dist/review/baseline.js +96 -0
- package/dist/review/baseline.test.d.ts +1 -0
- package/dist/review/baseline.test.js +22 -0
- package/dist/review/branch-checks.d.ts +6 -0
- package/dist/review/branch-checks.js +83 -0
- package/dist/review/branch-checks.test.d.ts +1 -0
- package/dist/review/branch-checks.test.js +48 -0
- package/dist/review/code-patterns.test.d.ts +1 -0
- package/dist/review/code-patterns.test.js +84 -0
- package/dist/review/dead-code.test.d.ts +1 -0
- package/dist/review/dead-code.test.js +126 -0
- package/dist/review/duplicate-functions.d.ts +2 -0
- package/dist/review/duplicate-functions.js +98 -0
- package/dist/review/effectiveness.d.ts +3 -0
- package/dist/review/optional-params.d.ts +2 -0
- package/dist/review/optional-params.js +123 -0
- package/dist/review/orphan-files.d.ts +2 -0
- package/dist/review/orphan-files.js +125 -0
- package/dist/review/package-layout.d.ts +9 -0
- package/dist/review/package-layout.js +108 -0
- package/dist/review/query-patterns.d.ts +2 -0
- package/dist/review/query-patterns.js +153 -0
- package/dist/review/quiet.js +1 -1
- package/dist/review/review.d.ts +2 -0
- package/dist/review/review.js +29 -1
- package/dist/review/review.test.js +28 -2
- package/dist/review/reviewer.d.ts +55 -0
- package/dist/review/reviewer.js +224 -0
- package/dist/review/reviewer.test.d.ts +1 -0
- package/dist/review/reviewer.test.js +99 -0
- package/dist/review/test-files.d.ts +2 -0
- package/dist/review/test-files.js +4 -0
- package/dist/review/toolchain.d.ts +11 -0
- package/dist/review/toolchain.js +126 -0
- package/dist/review/toolchain.test.d.ts +1 -0
- package/dist/review/toolchain.test.js +67 -0
- package/dist/review/unused-exports.d.ts +4 -0
- package/dist/review/unused-exports.js +189 -0
- package/dist/settings.js +2 -3
- package/dist/storage/db.js +1 -2
- package/dist/storage/team-scope.d.ts +20 -0
- package/dist/storage/team-scope.js +32 -0
- package/dist/storage/team-scope.test.d.ts +1 -0
- package/dist/storage/team-scope.test.js +22 -0
- package/dist/storage/team-store.d.ts +5 -0
- package/dist/storage/team-store.js +42 -7
- package/dist/storage/team-sync-scope.test.d.ts +1 -0
- package/dist/storage/team-sync-scope.test.js +67 -0
- package/dist/telemetry/telemetry.js +8 -6
- package/dist/telemetry/telemetry.test.js +5 -0
- package/dist/templates/universal-config.js +23 -0
- package/dist/types/index.d.ts +253 -6
- package/dist/types/index.js +27 -0
- package/dist/utils/installed-bin.d.ts +2 -0
- package/dist/utils/installed-bin.js +15 -0
- package/dist/utils/profile.d.ts +30 -0
- package/dist/utils/profile.js +116 -0
- package/dist/utils/profile.test.d.ts +1 -0
- package/dist/utils/profile.test.js +58 -0
- package/dist/utils/scanner.js +3 -1
- package/dist/utils/scanner.test.js +6 -2
- package/dist/utils/user-state.d.ts +3 -1
- package/dist/utils/user-state.js +6 -2
- package/package.json +10 -6
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { splitIntroduced } from './baseline.js';
|
|
3
|
+
const finding = (file, title, details = 'd') => ({ id: 'AST_COMPLEXITY', title, details, files: [file] });
|
|
4
|
+
describe('splitIntroduced', () => {
|
|
5
|
+
it("drops what the base already had, even when the change moved its numbers", () => {
|
|
6
|
+
const base = { failures: [finding('a.ts', "Function 'f' has complexity of 105 (max: 10)", 'L12: x')], added: new Set() };
|
|
7
|
+
const head = [finding('a.ts', "Function 'f' has complexity of 109 (max: 10)", 'L19: x'), finding('a.ts', "Function 'g' has complexity of 11 (max: 10)")];
|
|
8
|
+
const split = splitIntroduced(head, base);
|
|
9
|
+
expect(split.preexisting.map(f => f.title)).toEqual(["Function 'f' has complexity of 109 (max: 10)"]);
|
|
10
|
+
expect(split.introduced.map(f => f.title)).toEqual(["Function 'g' has complexity of 11 (max: 10)"]);
|
|
11
|
+
});
|
|
12
|
+
it('matches each base finding once, so a second copy of an old problem is new', () => {
|
|
13
|
+
const old = finding('a.ts', 'Unhandled promise', 'L3: then');
|
|
14
|
+
const split = splitIntroduced([old, finding('a.ts', 'Unhandled promise', 'L9: then')], { failures: [old], added: new Set() });
|
|
15
|
+
expect(split.preexisting).toHaveLength(1);
|
|
16
|
+
expect(split.introduced).toHaveLength(1);
|
|
17
|
+
});
|
|
18
|
+
it('never treats a finding in a file the change adds as old', () => {
|
|
19
|
+
const f = finding('new.ts', 'Naming drift');
|
|
20
|
+
expect(splitIntroduced([f], { failures: [f], added: new Set(['new.ts']) }).introduced).toEqual([f]);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Config, Failure } from '../types/index.js';
|
|
2
|
+
export declare function branchFailures(cwd: string, base: string, mainRef: string, config: Config): Failure[];
|
|
3
|
+
/** Files that conflict when the branch merges into `mainRef`; empty when it merges cleanly or git cannot say. */
|
|
4
|
+
export declare function mergeConflicts(cwd: string, mainRef: string): Failure[];
|
|
5
|
+
/** Places that still name a file the branch deleted (since `base`). */
|
|
6
|
+
export declare function staleReferences(cwd: string, base: string, config: Config): Failure[];
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks on a branch as a whole, not on the lines it changed, run before an agent stops or pushes:
|
|
3
|
+
*
|
|
4
|
+
* - merge-conflict: the branch no longer merges cleanly into main. A reviewer cannot approve it,
|
|
5
|
+
* and CI on the merge result never runs. `git merge-tree --write-tree` (git 2.38+) answers without
|
|
6
|
+
* touching the working tree; its exit status, not `--quiet`, says whether it conflicts.
|
|
7
|
+
* - stale-reference: a file the branch deleted is still named in another file (a comment, a doc,
|
|
8
|
+
* a script), so the next reader follows a path that no longer exists.
|
|
9
|
+
*
|
|
10
|
+
* Both are certain, so both block; neither reads beyond git and the files it names.
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from 'child_process';
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import { ownOutputs } from './unused-exports.js';
|
|
16
|
+
const GIT_TIMEOUT_MS = 30_000;
|
|
17
|
+
/** Files a deleted path can still be named in: code, docs, config and scripts. */
|
|
18
|
+
const MENTIONER = /\.(ts|tsx|js|jsx|mjs|cjs|svelte|md|mdx|json|ya?ml|toml|sh)$/;
|
|
19
|
+
export function branchFailures(cwd, base, mainRef, config) {
|
|
20
|
+
return [...mergeConflicts(cwd, mainRef), ...staleReferences(cwd, base, config)];
|
|
21
|
+
}
|
|
22
|
+
/** Files that conflict when the branch merges into `mainRef`; empty when it merges cleanly or git cannot say. */
|
|
23
|
+
export function mergeConflicts(cwd, mainRef) {
|
|
24
|
+
const result = spawnSync('git', ['merge-tree', '--write-tree', '--name-only', '--no-messages', 'HEAD', mainRef], { cwd, encoding: 'utf8', timeout: GIT_TIMEOUT_MS });
|
|
25
|
+
if (result.status !== 1)
|
|
26
|
+
return []; // 0: clean; anything else: git too old or the refs unknown
|
|
27
|
+
const files = result.stdout.split('\n').slice(1).map(line => line.trim()).filter(Boolean);
|
|
28
|
+
const main = mainRef.replace(/^refs\/(remotes\/|heads\/)/, '');
|
|
29
|
+
return [...new Set(files)].map(file => ({
|
|
30
|
+
id: 'merge-conflict',
|
|
31
|
+
title: 'Merge conflict',
|
|
32
|
+
details: `\`${file}\` conflicts with \`${main}\`: the branch no longer merges cleanly.`,
|
|
33
|
+
severity: 'high',
|
|
34
|
+
provenance: 'traditional',
|
|
35
|
+
files: [file],
|
|
36
|
+
line: 1,
|
|
37
|
+
hint: `Merge or rebase onto \`${main}\` and resolve \`${file}\`.`,
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
/** Places that still name a file the branch deleted (since `base`). */
|
|
41
|
+
export function staleReferences(cwd, base, config) {
|
|
42
|
+
const deleted = git(cwd, ['diff', '--name-only', '--diff-filter=D', base])?.split('\n').filter(Boolean) ?? [];
|
|
43
|
+
if (deleted.length === 0)
|
|
44
|
+
return [];
|
|
45
|
+
const own = ownOutputs(config);
|
|
46
|
+
const files = git(cwd, ['ls-files', '--cached', '--others', '--exclude-standard'])?.split('\n')
|
|
47
|
+
.filter(file => file && MENTIONER.test(file) && !own.some(o => file === o || file.startsWith(`${o}/`))) ?? [];
|
|
48
|
+
// The whole path, not a prefix of a longer one: a deleted `src/a.ts` is not `src/a.tsx`.
|
|
49
|
+
const mentions = deleted.map(gone => [gone, new RegExp(`(?<![\\w./-])${gone.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w./-])`)]);
|
|
50
|
+
const failures = [];
|
|
51
|
+
for (const file of files) {
|
|
52
|
+
let text;
|
|
53
|
+
try {
|
|
54
|
+
text = fs.readFileSync(path.join(cwd, file), 'utf8');
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const lines = text.split('\n');
|
|
60
|
+
for (const [gone, pattern] of mentions) {
|
|
61
|
+
const index = lines.findIndex(line => pattern.test(line));
|
|
62
|
+
if (index >= 0)
|
|
63
|
+
failures.push(stale(file, index + 1, gone));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return failures;
|
|
67
|
+
}
|
|
68
|
+
function stale(file, line, gone) {
|
|
69
|
+
return {
|
|
70
|
+
id: 'stale-reference',
|
|
71
|
+
title: 'Reference to a deleted file',
|
|
72
|
+
details: `\`${file}\` still names \`${gone}\`, which this branch deleted.`,
|
|
73
|
+
severity: 'medium',
|
|
74
|
+
provenance: 'traditional',
|
|
75
|
+
files: [file],
|
|
76
|
+
line,
|
|
77
|
+
hint: `Update or remove the mention of \`${gone}\`.`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function git(cwd, args) {
|
|
81
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 });
|
|
82
|
+
return result.status === 0 ? result.stdout : undefined;
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { ConfigSchema } from '../types/index.js';
|
|
7
|
+
import { mergeConflicts, staleReferences } from './branch-checks.js';
|
|
8
|
+
let repo;
|
|
9
|
+
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
|
|
10
|
+
const write = (rel, body) => {
|
|
11
|
+
fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
|
|
12
|
+
fs.writeFileSync(path.join(repo, rel), body);
|
|
13
|
+
};
|
|
14
|
+
const config = ConfigSchema.parse({ version: 1 });
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'branch-checks-'));
|
|
17
|
+
git('init', '-q', '-b', 'main');
|
|
18
|
+
git('config', 'user.email', 't@example.com');
|
|
19
|
+
git('config', 'user.name', 't');
|
|
20
|
+
git('config', 'commit.gpgsign', 'false');
|
|
21
|
+
write('src/limits.ts', 'export const LIMIT = 1;\n');
|
|
22
|
+
write('scripts/seed.ts', 'console.log(1);\n');
|
|
23
|
+
write('scripts/seed.tsx', 'console.log(2);\n');
|
|
24
|
+
git('add', '-A');
|
|
25
|
+
git('commit', '-qm', 'init');
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
|
|
28
|
+
describe('branch checks', () => {
|
|
29
|
+
it('reports the files that conflict with main, and nothing when it merges cleanly', () => {
|
|
30
|
+
git('checkout', '-q', '-b', 'feature');
|
|
31
|
+
write('src/limits.ts', 'export const LIMIT = 2;\n');
|
|
32
|
+
git('commit', '-qam', 'feature');
|
|
33
|
+
expect(mergeConflicts(repo, 'main')).toEqual([]);
|
|
34
|
+
git('checkout', '-q', 'main');
|
|
35
|
+
write('src/limits.ts', 'export const LIMIT = 3;\n');
|
|
36
|
+
git('commit', '-qam', 'main moved');
|
|
37
|
+
git('checkout', '-q', 'feature');
|
|
38
|
+
expect(mergeConflicts(repo, 'main').map(f => [f.id, f.files?.[0]])).toEqual([['merge-conflict', 'src/limits.ts']]);
|
|
39
|
+
});
|
|
40
|
+
it('reports a mention of a file the branch deleted, but not of a longer path that starts with it', () => {
|
|
41
|
+
const base = git('rev-parse', 'HEAD').trim();
|
|
42
|
+
git('rm', '-q', 'scripts/seed.ts');
|
|
43
|
+
write('README.md', 'Run `scripts/seed.ts` first.\nThen scripts/seed.tsx.\n');
|
|
44
|
+
write('src/notes.ts', '// see scripts/seed.tsx\nexport const n = 1;\n');
|
|
45
|
+
const found = staleReferences(repo, base, config).map(f => [f.files?.[0], f.line]);
|
|
46
|
+
expect(found).toEqual([['README.md', 1]]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { ConfigSchema } from '../types/index.js';
|
|
7
|
+
import { parseDiff } from '../utils/diff.js';
|
|
8
|
+
import { diffFromGit } from './git-diff.js';
|
|
9
|
+
import { duplicateFunctionFailures } from './duplicate-functions.js';
|
|
10
|
+
import { optionalParamFailures } from './optional-params.js';
|
|
11
|
+
import { queryPatternFailures } from './query-patterns.js';
|
|
12
|
+
let repo;
|
|
13
|
+
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
|
|
14
|
+
const write = (rel, body) => {
|
|
15
|
+
fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
|
|
16
|
+
fs.writeFileSync(path.join(repo, rel), body);
|
|
17
|
+
};
|
|
18
|
+
const config = ConfigSchema.parse({ version: 1 });
|
|
19
|
+
const changed = () => parseDiff(diffFromGit(repo));
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'code-patterns-'));
|
|
22
|
+
git('init', '-q', '-b', 'main');
|
|
23
|
+
git('config', 'user.email', 't@example.com');
|
|
24
|
+
git('config', 'user.name', 't');
|
|
25
|
+
git('config', 'commit.gpgsign', 'false');
|
|
26
|
+
write('README.md', 'x\n');
|
|
27
|
+
git('add', '-A');
|
|
28
|
+
git('commit', '-qm', 'init');
|
|
29
|
+
});
|
|
30
|
+
afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
|
|
31
|
+
describe('query patterns', () => {
|
|
32
|
+
it('reports offset paging in a loop and in a pager callback, not a single page a request asks for', () => {
|
|
33
|
+
write('src/sweep.ts', [
|
|
34
|
+
'export async function sweep(db: any) {',
|
|
35
|
+
' for (let from = 0; ; from += 500) {',
|
|
36
|
+
" const { data } = await db.from('rows').select('id').order('id').range(from, from + 499);",
|
|
37
|
+
' if (!data?.length) return;',
|
|
38
|
+
' }',
|
|
39
|
+
'}',
|
|
40
|
+
"export const all = (db: any) => readAllPages((from: number, to: number) => db.from('rows').select('id').order('id').range(from, to));",
|
|
41
|
+
"export function page(db: any, from: number, to: number) { return db.from('rows').select('id').range(from, to); }",
|
|
42
|
+
'declare function readAllPages(f: (from: number, to: number) => unknown): unknown;',
|
|
43
|
+
].join('\n'));
|
|
44
|
+
expect(queryPatternFailures(repo, changed(), config).map(f => [f.id, f.line])).toEqual([['offset-paging', 3], ['offset-paging', 7]]);
|
|
45
|
+
});
|
|
46
|
+
it('reports a window read only from its start, not a bounded window or everything since an event', () => {
|
|
47
|
+
write('src/window.ts', [
|
|
48
|
+
"export const open = (db: any, window: { from: string; to: string }) => db.from('events').select('id').gte('created_at', window.from);",
|
|
49
|
+
"export const closed = (db: any, window: { from: string; to: string }) => db.from('events').select('id').gte('created_at', window.from).lte('created_at', window.to);",
|
|
50
|
+
"export const since = (db: any, since: string) => db.from('events').select('id').gt('started_at', since);",
|
|
51
|
+
].join('\n'));
|
|
52
|
+
expect(queryPatternFailures(repo, changed(), config).map(f => [f.id, f.line])).toEqual([['unbounded-window', 1]]);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe('optional for tests', () => {
|
|
56
|
+
it('reports an optional parameter or option that every production call passes and only a test omits', () => {
|
|
57
|
+
write('src/build.ts', 'export function build(items: string[], opts: { resumes?: string[] }, limit?: number) { return [items, opts, limit]; }\n');
|
|
58
|
+
write('src/run.ts', "import { build } from './build';\nbuild(['a'], { resumes: [] }, 5);\n");
|
|
59
|
+
write('src/build.test.ts', "import { build } from './build';\nbuild(['a'], {});\n");
|
|
60
|
+
const found = optionalParamFailures(repo, changed(), config).map(f => f.details.match(/`([^`]+)`/)[1]).sort();
|
|
61
|
+
expect(found).toEqual(['limit', 'resumes']);
|
|
62
|
+
});
|
|
63
|
+
it('stays quiet when production itself omits it, or when tests pass it too', () => {
|
|
64
|
+
write('src/build.ts', 'export function build(items: string[], limit?: number) { return [items, limit]; }\n');
|
|
65
|
+
write('src/run.ts', "import { build } from './build';\nbuild(['a']);\nbuild(['b'], 2);\n");
|
|
66
|
+
write('src/build.test.ts', "import { build } from './build';\nbuild(['a']);\n");
|
|
67
|
+
expect(optionalParamFailures(repo, changed(), config)).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('duplicate functions', () => {
|
|
71
|
+
const body = (name) => `export function ${name}(x: number) {\n const a = x + 1;\n const b = a * 2;\n if (b > 10) return b;\n console.log(a, b);\n return a;\n}\n`;
|
|
72
|
+
it('reports a changed function whose body copies another, in TypeScript and Svelte alike', () => {
|
|
73
|
+
write('src/a.ts', body('first'));
|
|
74
|
+
write('src/b.svelte', `<script lang="ts">\n${body('second').replace('export ', '')}</script>\n<p>hi</p>\n`);
|
|
75
|
+
const found = duplicateFunctionFailures(repo, changed(), config);
|
|
76
|
+
expect(found.map(f => [f.files?.[0], f.line])).toEqual([['src/b.svelte', 2]]);
|
|
77
|
+
expect(found[0].details).toContain('`first` in `src/a.ts:1`');
|
|
78
|
+
});
|
|
79
|
+
it('ignores small look-alike functions', () => {
|
|
80
|
+
write('src/a.ts', 'export const one = (x: number) => { return x + 1; };\n');
|
|
81
|
+
write('src/b.ts', 'export const two = (x: number) => { return x + 1; };\n');
|
|
82
|
+
expect(duplicateFunctionFailures(repo, changed(), config)).toEqual([]);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { ConfigSchema } from '../types/index.js';
|
|
7
|
+
import { diffFromGit } from './git-diff.js';
|
|
8
|
+
import { orphanFileFailures } from './orphan-files.js';
|
|
9
|
+
import { unusedExportFailures } from './unused-exports.js';
|
|
10
|
+
let repo;
|
|
11
|
+
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
|
|
12
|
+
const write = (rel, body) => {
|
|
13
|
+
fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
|
|
14
|
+
fs.writeFileSync(path.join(repo, rel), body);
|
|
15
|
+
};
|
|
16
|
+
const config = (gates = {}) => ConfigSchema.parse({ version: 1, gates });
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'dead-code-'));
|
|
19
|
+
git('init', '-q', '-b', 'main');
|
|
20
|
+
git('config', 'user.email', 't@example.com');
|
|
21
|
+
git('config', 'user.name', 't');
|
|
22
|
+
git('config', 'commit.gpgsign', 'false');
|
|
23
|
+
write('src/index.ts', "import { start } from './app';\nstart();\n");
|
|
24
|
+
write('src/app.ts', 'export function start() {}\n');
|
|
25
|
+
write('package.json', '{"scripts":{"seed":"node scripts/seed.mjs"}}\n');
|
|
26
|
+
git('add', '-A');
|
|
27
|
+
git('commit', '-qm', 'init');
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
|
|
30
|
+
describe('unused exports', () => {
|
|
31
|
+
it('reports an added export no other file names, on its own line', () => {
|
|
32
|
+
write('src/app.ts', 'export function start() {}\n\nexport const LIMIT = 5;\nexport function lonely() { return LIMIT; }\n');
|
|
33
|
+
write('src/use.ts', "import { LIMIT } from './app';\nconsole.log(LIMIT);\n");
|
|
34
|
+
const failures = unusedExportFailures(repo, diffFromGit(repo), config());
|
|
35
|
+
expect(failures.map(f => [f.files?.[0], f.line, f.id])).toEqual([['src/app.ts', 4, 'unused-export']]);
|
|
36
|
+
expect(failures[0].details).toContain('`lonely`');
|
|
37
|
+
});
|
|
38
|
+
it('reports a re-export nothing imports, without counting the module it comes from as a use', () => {
|
|
39
|
+
write('src/types.ts', 'export type Row = { id: string };\nexport type Used = { n: number };\n');
|
|
40
|
+
write('src/index.ts', "import { start } from './app';\nstart();\nexport type { Row, Used } from './types';\n");
|
|
41
|
+
write('src/consumer.ts', "import type { Used } from './index';\nexport const u: Used = { n: 1 };\nconsole.log(u);\n");
|
|
42
|
+
const names = unusedExportFailures(repo, diffFromGit(repo), config()).map(f => [f.files?.[0], f.details.match(/`([^`]+)`/)[1]]);
|
|
43
|
+
expect(names).toContainEqual(['src/index.ts', 'Row']);
|
|
44
|
+
expect(names).not.toContainEqual(['src/index.ts', 'Used']);
|
|
45
|
+
});
|
|
46
|
+
it('skips what a framework calls by name, and names the team allows', () => {
|
|
47
|
+
write('src/routes/+page.server.ts', 'export const load = () => ({});\nexport const helper = 1;\n');
|
|
48
|
+
write('src/plugin.ts', 'export const register = () => {};\n');
|
|
49
|
+
const names = unusedExportFailures(repo, diffFromGit(repo), config({ unused_exports: { allow: ['register'] } }))
|
|
50
|
+
.map(f => f.details.match(/`([^`]+)`/)[1]);
|
|
51
|
+
expect(names).toEqual(['helper']);
|
|
52
|
+
});
|
|
53
|
+
it('does not count the same word in a file that never imports the module', () => {
|
|
54
|
+
write('src/payload.ts', 'export function eventId(kind: string) { return kind; }\nexport const used = eventId("x");\n');
|
|
55
|
+
write('src/main2.ts', "import { used } from './payload';\nconsole.log(used);\n");
|
|
56
|
+
write('src/routes/[eventId]/handler.ts', "const eventId = 'route-param';\nconsole.log(eventId);\n");
|
|
57
|
+
write('src/schedule.ts', 'const eventId = 7;\nconsole.log(eventId);\n');
|
|
58
|
+
const names = unusedExportFailures(repo, diffFromGit(repo), config()).map(f => f.details.match(/`([^`]+)`/)[1]);
|
|
59
|
+
expect(names).toContain('eventId');
|
|
60
|
+
expect(names).not.toContain('used');
|
|
61
|
+
});
|
|
62
|
+
it('reports a type imported then exported again that nothing imports from here', () => {
|
|
63
|
+
write('src/entry.ts', "export type Entry = 'a' | 'b';\n");
|
|
64
|
+
write('src/resume.ts', "import type { Entry } from './entry';\nexport type { Entry };\nexport const pick = (e: Entry) => e;\n");
|
|
65
|
+
write('src/main.ts', "import { pick } from './resume';\nconsole.log(pick('a'));\n");
|
|
66
|
+
const found = unusedExportFailures(repo, diffFromGit(repo), config()).map(f => [f.files?.[0], f.details.match(/`([^`]+)`/)[1]]);
|
|
67
|
+
expect(found).toContainEqual(['src/resume.ts', 'Entry']);
|
|
68
|
+
expect(found).not.toContainEqual(['src/entry.ts', 'Entry']);
|
|
69
|
+
});
|
|
70
|
+
it("never counts Rigour's own report as a use", () => {
|
|
71
|
+
write('src/app.ts', 'export function start() {}\nexport const orphanName = 1;\n');
|
|
72
|
+
write('rigour-report.json', '{"failures":[{"details":"orphanName"}]}\n');
|
|
73
|
+
expect(unusedExportFailures(repo, diffFromGit(repo), config())).toHaveLength(1);
|
|
74
|
+
});
|
|
75
|
+
it('is off when disabled', () => {
|
|
76
|
+
write('src/app.ts', 'export function start() {}\nexport const x = 1;\n');
|
|
77
|
+
expect(unusedExportFailures(repo, diffFromGit(repo), config({ unused_exports: { enabled: false } }))).toEqual([]);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
describe('package layout', () => {
|
|
81
|
+
it("never reports a package entry point's exports, which are its public API", () => {
|
|
82
|
+
write('packages/lib/package.json', '{"name":"lib","main":"dist/index.js","types":"dist/index.d.ts"}\n');
|
|
83
|
+
write('packages/lib/src/index.ts', "export { helper, type Shape } from './helper';\n");
|
|
84
|
+
write('packages/lib/src/helper.ts', 'export type Shape = { a: 1 };\nexport const helper = 1;\n');
|
|
85
|
+
write('packages/lib/src/tools/index.ts', "export { toolA } from './a';\n");
|
|
86
|
+
write('packages/lib/src/tools/a.ts', 'export const toolA = 1;\n');
|
|
87
|
+
write('packages/lib/src/index.ts', "export { helper, type Shape } from './helper';\nexport * from './tools/index.js';\n");
|
|
88
|
+
const reported = unusedExportFailures(repo, diffFromGit(repo), config()).map(f => f.files?.[0]);
|
|
89
|
+
expect(reported).not.toContain('packages/lib/src/index.ts');
|
|
90
|
+
expect(reported).not.toContain('packages/lib/src/tools/index.ts'); // re-exported whole by the entry
|
|
91
|
+
});
|
|
92
|
+
it('keeps an exported type in a signature when the project emits declarations, and only then', () => {
|
|
93
|
+
write('src/run.ts', 'export interface RunResult { ok: boolean }\nexport function run(): RunResult { return { ok: true }; }\n');
|
|
94
|
+
write('src/main.ts', "import { run } from './run';\nrun();\n");
|
|
95
|
+
const names = () => unusedExportFailures(repo, diffFromGit(repo), config()).map(f => f.details.match(/`([^`]+)`/)[1]);
|
|
96
|
+
expect(names()).toContain('RunResult'); // an application: drop the export
|
|
97
|
+
write('tsconfig.base.json', '{ "compilerOptions": { "declaration": true } }\n');
|
|
98
|
+
write('tsconfig.json', '{ "extends": "./tsconfig.base.json" }\n');
|
|
99
|
+
expect(names()).not.toContain('RunResult'); // a library: TypeScript needs it exported
|
|
100
|
+
});
|
|
101
|
+
it("counts a package.json bin or main path as running its source file", () => {
|
|
102
|
+
write('packages/tool/package.json', '{"name":"tool","bin":{"tool":"dist/bin.js"}}\n');
|
|
103
|
+
write('packages/tool/src/bin.ts', 'console.log("hi");\n');
|
|
104
|
+
expect(orphanFileFailures(repo, diffFromGit(repo), config())).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
describe('orphaned files', () => {
|
|
108
|
+
it('reports new files nothing outside the new files reaches, including a pair that only import each other', () => {
|
|
109
|
+
write('src/wired.ts', 'export const w = 1;\n');
|
|
110
|
+
write('src/index.ts', "import { start } from './app';\nimport { w } from './wired';\nstart(w);\n");
|
|
111
|
+
write('src/lonely.ts', 'export const l = 1;\n');
|
|
112
|
+
write('src/pair/a.ts', "import { b } from './b';\nexport const a = b;\n");
|
|
113
|
+
write('src/pair/b.ts', "import { a } from './a';\nexport const b = a;\n");
|
|
114
|
+
write('scripts/seed.mjs', 'console.log(1);\n');
|
|
115
|
+
write('src/tested.ts', 'export const t = 1;\n');
|
|
116
|
+
write('src/tested.test.ts', "import { t } from './tested';\n"); // a test runs it: reached
|
|
117
|
+
const files = orphanFileFailures(repo, diffFromGit(repo), config()).map(f => f.files?.[0]).sort();
|
|
118
|
+
expect(files).toEqual(['src/lonely.ts', 'src/pair/a.ts', 'src/pair/b.ts']);
|
|
119
|
+
});
|
|
120
|
+
it('lets a file through when a docs page is its only mention, and honours allow globs', () => {
|
|
121
|
+
write('src/tool.ts', 'export const t = 1;\n');
|
|
122
|
+
write('docs/tool.md', 'Run `src/tool.ts` by hand.\n');
|
|
123
|
+
expect(orphanFileFailures(repo, diffFromGit(repo), config()).map(f => f.files?.[0])).toEqual(['src/tool.ts']);
|
|
124
|
+
expect(orphanFileFailures(repo, diffFromGit(repo), config({ orphan_files: { allow: ['src/tool.ts'] } }))).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duplicate function: a function the change adds or edits whose body is, line for line, the same as
|
|
3
|
+
* another function's in the files the change touched. Two copies drift: the next fix lands in one.
|
|
4
|
+
* Bodies are compared as code (comments and layout ignored), and only bodies of real size count,
|
|
5
|
+
* so small adapters that happen to match are not reported.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import ts from 'typescript';
|
|
10
|
+
import { isTestFile } from './test-files.js';
|
|
11
|
+
const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|svelte)$/;
|
|
12
|
+
const MIN_STATEMENTS = 4;
|
|
13
|
+
const MIN_LINES = 6;
|
|
14
|
+
export function duplicateFunctionFailures(cwd, changedLines, config) {
|
|
15
|
+
if (!config.gates.duplicate_functions?.enabled)
|
|
16
|
+
return [];
|
|
17
|
+
const bodies = [];
|
|
18
|
+
for (const [file, lines] of Object.entries(changedLines)) {
|
|
19
|
+
if (!CODE.test(file) || isTestFile(file))
|
|
20
|
+
continue;
|
|
21
|
+
bodies.push(...bodiesIn(cwd, file, lines));
|
|
22
|
+
}
|
|
23
|
+
const byKey = new Map();
|
|
24
|
+
for (const body of bodies)
|
|
25
|
+
byKey.set(body.key, [...(byKey.get(body.key) ?? []), body]);
|
|
26
|
+
const failures = [];
|
|
27
|
+
for (const same of byKey.values()) {
|
|
28
|
+
if (same.length < 2)
|
|
29
|
+
continue;
|
|
30
|
+
for (const copy of same.filter(b => b.changed)) {
|
|
31
|
+
const original = same.find(b => b !== copy && (!b.changed || b.file < copy.file || (b.file === copy.file && b.line < copy.line)));
|
|
32
|
+
if (original)
|
|
33
|
+
failures.push(duplicate(copy, original));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return failures;
|
|
37
|
+
}
|
|
38
|
+
function bodiesIn(cwd, file, changed) {
|
|
39
|
+
let source;
|
|
40
|
+
let offset = 0; // a Svelte component's script starts below its first line
|
|
41
|
+
try {
|
|
42
|
+
let text = fs.readFileSync(path.join(cwd, file), 'utf8');
|
|
43
|
+
if (file.endsWith('.svelte')) {
|
|
44
|
+
const script = text.match(/<script[^>]*>([\s\S]*?)<\/script>/);
|
|
45
|
+
if (!script || script.index === undefined)
|
|
46
|
+
return [];
|
|
47
|
+
offset = text.slice(0, script.index + script[0].indexOf(script[1])).split('\n').length - 1;
|
|
48
|
+
text = script[1];
|
|
49
|
+
}
|
|
50
|
+
source = ts.createSourceFile(file.endsWith('.svelte') ? `${file}.ts` : file, text, ts.ScriptTarget.Latest, true);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
const found = [];
|
|
56
|
+
const visit = (node) => {
|
|
57
|
+
if ((ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) || ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && node.body && ts.isBlock(node.body)) {
|
|
58
|
+
const start = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1 + offset;
|
|
59
|
+
const end = source.getLineAndCharacterOfPosition(node.getEnd()).line + 1 + offset;
|
|
60
|
+
if (node.body.statements.length >= MIN_STATEMENTS && end - start + 1 >= MIN_LINES) {
|
|
61
|
+
const isChanged = [...changed].some(l => l >= start && l <= end);
|
|
62
|
+
found.push({ file, name: nameOf(node) ?? `function at line ${start}`, line: start, changed: isChanged, key: normalized(node.body, source) });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
ts.forEachChild(node, visit);
|
|
66
|
+
};
|
|
67
|
+
visit(source);
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
/** The body as tokens: comments and whitespace dropped. */
|
|
71
|
+
function normalized(body, source) {
|
|
72
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, true, ts.LanguageVariant.Standard, body.getText(source));
|
|
73
|
+
const tokens = [];
|
|
74
|
+
for (let kind = scanner.scan(); kind !== ts.SyntaxKind.EndOfFileToken; kind = scanner.scan())
|
|
75
|
+
tokens.push(scanner.getTokenText());
|
|
76
|
+
return tokens.join(' ');
|
|
77
|
+
}
|
|
78
|
+
function nameOf(node) {
|
|
79
|
+
if ((ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) && node.name && ts.isIdentifier(node.name))
|
|
80
|
+
return node.name.text;
|
|
81
|
+
if (ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name))
|
|
82
|
+
return node.parent.name.text;
|
|
83
|
+
if (ts.isPropertyAssignment(node.parent) && ts.isIdentifier(node.parent.name))
|
|
84
|
+
return node.parent.name.text;
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
function duplicate(copy, original) {
|
|
88
|
+
return {
|
|
89
|
+
id: 'duplicate-function',
|
|
90
|
+
title: 'Duplicate function',
|
|
91
|
+
details: `\`${copy.name}\` has the same body as \`${original.name}\` in \`${original.file}:${original.line}\`: two copies drift, and the next fix lands in one.`,
|
|
92
|
+
severity: 'medium',
|
|
93
|
+
provenance: 'traditional',
|
|
94
|
+
files: [copy.file],
|
|
95
|
+
line: copy.line,
|
|
96
|
+
hint: `Keep one: call \`${original.name}\` (moving it somewhere both can import), or extract the shared body.`,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -11,6 +11,9 @@ export interface AgentEvent {
|
|
|
11
11
|
blocked?: boolean;
|
|
12
12
|
/** Findings that blocked a stop. */
|
|
13
13
|
blocking?: number;
|
|
14
|
+
/** stop_review: what the review was measured against, and whether there was nothing to look at. */
|
|
15
|
+
against?: string;
|
|
16
|
+
nothing_to_review?: boolean;
|
|
14
17
|
/** lessons_served: where the agent got them, and their subjects (learning-events.ts). */
|
|
15
18
|
via?: string;
|
|
16
19
|
lessons?: string[];
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional only for tests: a parameter the change adds as optional (`p?: T`) that every call
|
|
3
|
+
* outside tests passes. Production never takes the "omitted" path, so it is untested where it
|
|
4
|
+
* matters and the type lies about what callers must provide. Calls are found by name in the files
|
|
5
|
+
* that import the function's module (and its own file), read from the syntax tree.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'child_process';
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import ts from 'typescript';
|
|
11
|
+
import { isTestFile } from './test-files.js';
|
|
12
|
+
const CODE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
13
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
14
|
+
export function optionalParamFailures(cwd, changedLines, config) {
|
|
15
|
+
if (!config.gates.optional_params?.enabled)
|
|
16
|
+
return [];
|
|
17
|
+
const failures = [];
|
|
18
|
+
for (const param of addedOptionalParams(cwd, changedLines)) {
|
|
19
|
+
const calls = callsOf(cwd, param);
|
|
20
|
+
const production = calls.filter(c => !isTestFile(c.file));
|
|
21
|
+
const tests = calls.filter(c => isTestFile(c.file));
|
|
22
|
+
if (production.length > 0 && production.every(c => passes(c, param)) && tests.some(c => !passes(c, param))) {
|
|
23
|
+
failures.push(onlyTestsOmit(param, production.length));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return failures;
|
|
27
|
+
}
|
|
28
|
+
/** Optional parameters (`p?: T`) on named functions, declared on lines the change added. */
|
|
29
|
+
function addedOptionalParams(cwd, changedLines) {
|
|
30
|
+
const found = [];
|
|
31
|
+
for (const [file, lines] of Object.entries(changedLines)) {
|
|
32
|
+
if (!CODE.test(file) || isTestFile(file) || lines.size === 0)
|
|
33
|
+
continue;
|
|
34
|
+
const source = parse(cwd, file);
|
|
35
|
+
if (!source)
|
|
36
|
+
continue;
|
|
37
|
+
const visit = (node) => {
|
|
38
|
+
const name = functionName(node);
|
|
39
|
+
if (name && ts.isFunctionLike(node)) {
|
|
40
|
+
node.parameters.forEach((p, index) => {
|
|
41
|
+
const line = source.getLineAndCharacterOfPosition(p.getStart(source)).line + 1;
|
|
42
|
+
if (p.questionToken && ts.isIdentifier(p.name) && lines.has(line) && !p.dotDotDotToken)
|
|
43
|
+
found.push({ file, fn: name, name: p.name.text, index, line });
|
|
44
|
+
if (!p.questionToken && p.type && ts.isTypeLiteralNode(p.type)) {
|
|
45
|
+
for (const member of p.type.members) {
|
|
46
|
+
const at = source.getLineAndCharacterOfPosition(member.getStart(source)).line + 1;
|
|
47
|
+
if (ts.isPropertySignature(member) && member.questionToken && ts.isIdentifier(member.name) && lines.has(at)) {
|
|
48
|
+
found.push({ file, fn: name, name: member.name.text, index, line: at, property: member.name.text });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
ts.forEachChild(node, visit);
|
|
55
|
+
};
|
|
56
|
+
visit(source);
|
|
57
|
+
}
|
|
58
|
+
return found;
|
|
59
|
+
}
|
|
60
|
+
function functionName(node) {
|
|
61
|
+
if (ts.isFunctionDeclaration(node))
|
|
62
|
+
return node.name?.text;
|
|
63
|
+
if ((ts.isArrowFunction(node) || ts.isFunctionExpression(node)) && ts.isVariableDeclaration(node.parent) && ts.isIdentifier(node.parent.name))
|
|
64
|
+
return node.parent.name.text;
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
/** Whether a call provides the parameter, or the property inside the object parameter. Unreadable calls count as providing it. */
|
|
68
|
+
function passes(call, param) {
|
|
69
|
+
if (call.args <= param.index)
|
|
70
|
+
return false;
|
|
71
|
+
if (!param.property)
|
|
72
|
+
return true;
|
|
73
|
+
return call.keys === null || call.keys === undefined || call.keys.has(param.property);
|
|
74
|
+
}
|
|
75
|
+
/** Calls of `param.fn` (`fn(…)` or `x.fn(…)`) in its own file and the files that name it. */
|
|
76
|
+
function callsOf(cwd, param) {
|
|
77
|
+
const grep = spawnSync('git', ['grep', '--untracked', '-l', '-w', '-F', '-e', param.fn], { cwd, encoding: 'utf8', timeout: GIT_TIMEOUT_MS });
|
|
78
|
+
const files = grep.status === 0 ? grep.stdout.split('\n').filter(f => f && CODE.test(f)) : [];
|
|
79
|
+
const calls = [];
|
|
80
|
+
for (const file of new Set([param.file, ...files])) {
|
|
81
|
+
const source = parse(cwd, file);
|
|
82
|
+
if (!source)
|
|
83
|
+
continue;
|
|
84
|
+
const visit = (node) => {
|
|
85
|
+
if (ts.isCallExpression(node)) {
|
|
86
|
+
const callee = node.expression;
|
|
87
|
+
const name = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) ? callee.name.text : undefined;
|
|
88
|
+
if (name === param.fn)
|
|
89
|
+
calls.push({ file, args: node.arguments.some(a => ts.isSpreadElement(a)) ? Infinity : node.arguments.length, keys: objectKeys(node.arguments[param.index]) });
|
|
90
|
+
}
|
|
91
|
+
ts.forEachChild(node, visit);
|
|
92
|
+
};
|
|
93
|
+
visit(source);
|
|
94
|
+
}
|
|
95
|
+
return calls;
|
|
96
|
+
}
|
|
97
|
+
function objectKeys(arg) {
|
|
98
|
+
if (!arg)
|
|
99
|
+
return undefined;
|
|
100
|
+
if (!ts.isObjectLiteralExpression(arg) || arg.properties.some(p => ts.isSpreadAssignment(p)))
|
|
101
|
+
return null;
|
|
102
|
+
return new Set(arg.properties.map(p => (p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) ? p.name.text : '')).filter(Boolean));
|
|
103
|
+
}
|
|
104
|
+
function parse(cwd, file) {
|
|
105
|
+
try {
|
|
106
|
+
return ts.createSourceFile(file, fs.readFileSync(path.join(cwd, file), 'utf8'), ts.ScriptTarget.Latest, true);
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function onlyTestsOmit(param, productionCalls) {
|
|
113
|
+
return {
|
|
114
|
+
id: 'optional-for-tests',
|
|
115
|
+
title: 'Optional only for tests',
|
|
116
|
+
details: `\`${param.name}\`${param.property ? ' (an option)' : ''} of \`${param.fn}\` is optional, but all ${productionCalls} call(s) outside tests pass it; only tests omit it.`,
|
|
117
|
+
severity: 'medium',
|
|
118
|
+
provenance: 'traditional',
|
|
119
|
+
files: [param.file],
|
|
120
|
+
line: param.line,
|
|
121
|
+
hint: `Make \`${param.name}\` required and pass it in the tests too, so the type says what callers must provide.`,
|
|
122
|
+
};
|
|
123
|
+
}
|