@rigour-labs/core 6.6.4 → 6.6.6
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/gates/frontend-secret-exposure.d.ts +1 -1
- package/dist/gates/frontend-secret-exposure.js +9 -3
- package/dist/gates/frontend-secret-exposure.test.js +4 -1
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/index.js +1 -1
- package/dist/hooks/stop-review.d.ts +6 -9
- package/dist/hooks/stop-review.js +5 -10
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- package/dist/review/backtest-init.d.ts +8 -0
- package/dist/review/backtest-init.js +87 -0
- package/dist/review/backtest-init.test.d.ts +1 -0
- package/dist/review/backtest-init.test.js +74 -0
- package/dist/review/backtest.d.ts +315 -0
- package/dist/review/backtest.js +197 -0
- package/dist/review/backtest.test.d.ts +1 -0
- package/dist/review/backtest.test.js +129 -0
- package/dist/review/code-patterns.test.js +58 -0
- package/dist/review/dead-code.test.js +8 -0
- package/dist/review/duplicate-functions.js +7 -2
- package/dist/review/lint-overlay.d.ts +21 -0
- package/dist/review/lint-overlay.js +89 -0
- package/dist/review/loop-copies.d.ts +2 -0
- package/dist/review/loop-copies.js +116 -0
- package/dist/review/migration-order.js +5 -1
- package/dist/review/orphan-files.js +5 -1
- package/dist/review/partial-fixes.d.ts +2 -0
- package/dist/review/partial-fixes.js +138 -0
- package/dist/review/partial-wiring.d.ts +2 -0
- package/dist/review/partial-wiring.js +201 -0
- package/dist/review/quiet.d.ts +6 -0
- package/dist/review/quiet.js +12 -2
- package/dist/review/quiet.test.js +11 -0
- package/dist/review/review.d.ts +6 -0
- package/dist/review/review.js +12 -2
- package/dist/review/reviewer/adapters.d.ts +35 -0
- package/dist/review/reviewer/adapters.js +156 -0
- package/dist/review/reviewer/background.d.ts +34 -0
- package/dist/review/reviewer/background.js +128 -0
- package/dist/review/reviewer/background.test.d.ts +1 -0
- package/dist/review/reviewer/background.test.js +104 -0
- package/dist/review/reviewer/exec.d.ts +17 -0
- package/dist/review/reviewer/exec.js +23 -0
- package/dist/review/reviewer/inputs.d.ts +37 -0
- package/dist/review/reviewer/inputs.js +103 -0
- package/dist/review/reviewer/merge-impact.d.ts +2 -0
- package/dist/review/reviewer/merge-impact.js +64 -0
- package/dist/review/reviewer/prompt.d.ts +21 -0
- package/dist/review/reviewer/prompt.js +123 -0
- package/dist/review/reviewer/store.d.ts +26 -0
- package/dist/review/reviewer/store.js +57 -0
- package/dist/review/reviewer/verdict.d.ts +127 -0
- package/dist/review/reviewer/verdict.js +156 -0
- package/dist/review/reviewer.d.ts +41 -49
- package/dist/review/reviewer.js +210 -194
- package/dist/review/reviewer.test.js +199 -48
- package/dist/review/toolchain.d.ts +5 -2
- package/dist/review/toolchain.js +81 -6
- package/dist/review/toolchain.test.js +39 -1
- package/dist/review/typed/program.d.ts +27 -0
- package/dist/review/typed/program.js +90 -0
- package/dist/review/typed/redundancy.d.ts +10 -0
- package/dist/review/typed/redundancy.js +339 -0
- package/dist/review/typed/redundancy.test.d.ts +1 -0
- package/dist/review/typed/redundancy.test.js +121 -0
- package/dist/review/unused-exports.js +8 -4
- package/dist/templates/universal-config.js +11 -1
- package/dist/types/index.d.ts +128 -8
- package/dist/types/index.js +26 -2
- package/package.json +6 -6
|
@@ -7,6 +7,9 @@ import { ConfigSchema } from '../types/index.js';
|
|
|
7
7
|
import { parseDiff } from '../utils/diff.js';
|
|
8
8
|
import { diffFromGit } from './git-diff.js';
|
|
9
9
|
import { duplicateFunctionFailures } from './duplicate-functions.js';
|
|
10
|
+
import { loopCopyFailures } from './loop-copies.js';
|
|
11
|
+
import { partialFixFailures } from './partial-fixes.js';
|
|
12
|
+
import { partialWiringFailures } from './partial-wiring.js';
|
|
10
13
|
import { optionalParamFailures } from './optional-params.js';
|
|
11
14
|
import { queryPatternFailures } from './query-patterns.js';
|
|
12
15
|
let repo;
|
|
@@ -82,3 +85,58 @@ describe('duplicate functions', () => {
|
|
|
82
85
|
expect(duplicateFunctionFailures(repo, changed(), config)).toEqual([]);
|
|
83
86
|
});
|
|
84
87
|
});
|
|
88
|
+
describe('quadratic copies', () => {
|
|
89
|
+
it('reports an accumulator spread into a new copy on every step, not a push', () => {
|
|
90
|
+
write('src/group.ts', [
|
|
91
|
+
'export function group(items: { day: string }[]) {',
|
|
92
|
+
' const groups = new Map<string, { day: string }[]>();',
|
|
93
|
+
' for (const item of items) groups.set(item.day, [...(groups.get(item.day) ?? []), item]);',
|
|
94
|
+
' const ids = items.reduce((acc: string[], item) => [...acc, item.day], []);',
|
|
95
|
+
' const fast = new Map<string, string[]>();',
|
|
96
|
+
' for (const item of items) (fast.get(item.day) ?? fast.set(item.day, []).get(item.day)!).push(item.day);',
|
|
97
|
+
' return { groups, ids, fast };',
|
|
98
|
+
'}',
|
|
99
|
+
].join('\n'));
|
|
100
|
+
expect(loopCopyFailures(repo, changed(), config).map(f => f.line)).toEqual([3, 4]);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
describe('partial fixes', () => {
|
|
104
|
+
it('names the places that still test the narrower condition a new predicate widened', () => {
|
|
105
|
+
write('src/routes/session/page.server.ts', 'export function load(snapshot: any) {\n if (snapshot.answeredCount > 0) return 1;\n return 0;\n}\n');
|
|
106
|
+
write('src/routes/session/page.svelte', '<script lang="ts">\n const show = data.snapshot.answeredCount > 0;\n</script>\n');
|
|
107
|
+
git('add', '-A');
|
|
108
|
+
git('commit', '-qm', 'before');
|
|
109
|
+
write('src/routes/session/page.server.ts', 'export function load(snapshot: any) {\n if (snapshot.answeredCount > 0) return 1;\n return 0;\n}\nexport const resume = (snapshot: any) => {\n const hasSaved = snapshot.answers.length > 0 || snapshot.answeredCount > 0;\n return hasSaved;\n};\n');
|
|
110
|
+
const found = partialFixFailures(repo, changed(), config);
|
|
111
|
+
expect(found.map(f => [f.id, f.line])).toEqual([['partial-fix', 6]]);
|
|
112
|
+
expect(found[0].details).toContain('page.server.ts:2');
|
|
113
|
+
expect(found[0].details).toContain('page.svelte:2');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
describe('partial wiring', () => {
|
|
117
|
+
const mount = (extra) => `<script lang="ts">\n import Player from '$lib/Player.svelte';\n</script>\n<Player\n mode="quiz"\n {questions}${extra}\n/>\n`;
|
|
118
|
+
it('reports a callback added to most same-kind mounts of a local component but not one', () => {
|
|
119
|
+
write('src/lib/Player.svelte', '<script lang="ts">let { onResume } = $props();</script>\n');
|
|
120
|
+
for (const route of ['a', 'b', 'c'])
|
|
121
|
+
write(`src/routes/${route}/+page.svelte`, mount(''));
|
|
122
|
+
write('src/routes/d/+page.svelte', mount('').replace('mode="quiz"', 'mode="exam"'));
|
|
123
|
+
git('add', '-A');
|
|
124
|
+
git('commit', '-qm', 'mounts');
|
|
125
|
+
write('src/routes/a/+page.svelte', mount('\n {onResume}'));
|
|
126
|
+
write('src/routes/b/+page.svelte', mount('\n {onResume}'));
|
|
127
|
+
const found = partialWiringFailures(repo, changed(), config);
|
|
128
|
+
expect(found).toHaveLength(1);
|
|
129
|
+
expect(found[0].details).toContain('src/routes/c/+page.svelte');
|
|
130
|
+
expect(found[0].details).not.toContain('routes/d'); // another kind of mount
|
|
131
|
+
});
|
|
132
|
+
it('ignores styling props and components from packages', () => {
|
|
133
|
+
const icon = (extra) => `<script lang="ts">\n import { Star } from 'lucide-svelte';\n</script>\n<Star class="h-4"${extra} />\n`;
|
|
134
|
+
for (const route of ['a', 'b', 'c'])
|
|
135
|
+
write(`src/routes/${route}/+page.svelte`, icon(''));
|
|
136
|
+
git('add', '-A');
|
|
137
|
+
git('commit', '-qm', 'icons');
|
|
138
|
+
write('src/routes/a/+page.svelte', icon(' onHover={go}'));
|
|
139
|
+
write('src/routes/b/+page.svelte', icon(' onHover={go}'));
|
|
140
|
+
expect(partialWiringFailures(repo, changed(), config)).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -35,6 +35,14 @@ describe('unused exports', () => {
|
|
|
35
35
|
expect(failures.map(f => [f.files?.[0], f.line, f.id])).toEqual([['src/app.ts', 4, 'unused-export']]);
|
|
36
36
|
expect(failures[0].details).toContain('`lonely`');
|
|
37
37
|
});
|
|
38
|
+
it('does not count a test as a consumer: an export only its tests import is dead in production', () => {
|
|
39
|
+
write('src/app.ts', 'export function start() {}\n\nexport function onlyTested() { return 1; }\nexport function used() { return 2; }\n');
|
|
40
|
+
write('src/app.test.ts', "import { onlyTested, used } from './app';\nonlyTested();\nused();\n");
|
|
41
|
+
write('tests/helpers.ts', "import { onlyTested } from '../src/app';\nonlyTested();\n");
|
|
42
|
+
write('src/use.ts', "import { used } from './app';\nused();\n");
|
|
43
|
+
const names = unusedExportFailures(repo, diffFromGit(repo), config()).map(f => f.details.match(/`([^`]+)`/)[1]);
|
|
44
|
+
expect(names).toEqual(['onlyTested']);
|
|
45
|
+
});
|
|
38
46
|
it('reports a re-export nothing imports, without counting the module it comes from as a use', () => {
|
|
39
47
|
write('src/types.ts', 'export type Row = { id: string };\nexport type Used = { n: number };\n');
|
|
40
48
|
write('src/index.ts', "import { start } from './app';\nstart();\nexport type { Row, Used } from './types';\n");
|
|
@@ -21,8 +21,13 @@ export function duplicateFunctionFailures(cwd, changedLines, config) {
|
|
|
21
21
|
bodies.push(...bodiesIn(cwd, file, lines));
|
|
22
22
|
}
|
|
23
23
|
const byKey = new Map();
|
|
24
|
-
for (const body of bodies)
|
|
25
|
-
|
|
24
|
+
for (const body of bodies) {
|
|
25
|
+
const same = byKey.get(body.key);
|
|
26
|
+
if (same)
|
|
27
|
+
same.push(body);
|
|
28
|
+
else
|
|
29
|
+
byKey.set(body.key, [body]);
|
|
30
|
+
}
|
|
26
31
|
const failures = [];
|
|
27
32
|
for (const same of byKey.values()) {
|
|
28
33
|
if (same.length < 2)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface OverlayMessage {
|
|
2
|
+
file: string;
|
|
3
|
+
line: number;
|
|
4
|
+
column: number;
|
|
5
|
+
rule: string;
|
|
6
|
+
message: string;
|
|
7
|
+
}
|
|
8
|
+
/** Why the overlay cannot run here, or undefined when it can. */
|
|
9
|
+
export declare function overlayUnavailable(cwd: string): string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Writes the overlay config to a scratch directory and returns its path. It is an ES module that
|
|
12
|
+
* imports the repository's config by absolute path, so it is never written into the repository.
|
|
13
|
+
*/
|
|
14
|
+
export declare function writeOverlayConfig(cwd: string): string;
|
|
15
|
+
/** ESLint's JSON report, kept to messages on changed lines; the rest is counted. */
|
|
16
|
+
export declare function onChangedLines(json: string, cwd: string, changedLines: Record<string, Set<number>>): {
|
|
17
|
+
blocking: OverlayMessage[];
|
|
18
|
+
preexisting: number;
|
|
19
|
+
} | {
|
|
20
|
+
error: string;
|
|
21
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-checked lint rules the repository does not enable yet, on changed lines only. They are the
|
|
3
|
+
* mechanical dead-code classes a person keeps finding after lint is green: a condition that can
|
|
4
|
+
* never be true once the types say so, a `?.` on a member every host supplies, a redundant
|
|
5
|
+
* comparison, a promise nobody awaits. The overlay loads the repository's own flat config first,
|
|
6
|
+
* so nothing it enforces is lost, and uses its own `typescript-eslint` (never a download). A
|
|
7
|
+
* message on a line the change did not touch is the team's backlog: counted, not blocking.
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'fs';
|
|
10
|
+
import { createRequire } from 'module';
|
|
11
|
+
import os from 'os';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { pathToFileURL } from 'url';
|
|
14
|
+
const CONFIG_FILES = ['eslint.config.js', 'eslint.config.mjs', 'eslint.config.cjs', 'eslint.config.ts', 'eslint.config.mts'];
|
|
15
|
+
const TYPED_RULES = {
|
|
16
|
+
'@typescript-eslint/no-unnecessary-condition': 'error',
|
|
17
|
+
'@typescript-eslint/no-unnecessary-boolean-literal-compare': 'error',
|
|
18
|
+
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
|
|
19
|
+
'@typescript-eslint/no-redundant-type-constituents': 'error',
|
|
20
|
+
'@typescript-eslint/prefer-optional-chain': 'error',
|
|
21
|
+
'@typescript-eslint/switch-exhaustiveness-check': 'error',
|
|
22
|
+
'@typescript-eslint/no-floating-promises': 'error',
|
|
23
|
+
'@typescript-eslint/no-misused-promises': 'error',
|
|
24
|
+
};
|
|
25
|
+
/** Why the overlay cannot run here, or undefined when it can. */
|
|
26
|
+
export function overlayUnavailable(cwd) {
|
|
27
|
+
if (!CONFIG_FILES.some(name => fs.existsSync(path.join(cwd, name))))
|
|
28
|
+
return 'no eslint.config.* in this repository';
|
|
29
|
+
try {
|
|
30
|
+
createRequire(path.join(cwd, 'package.json')).resolve('typescript-eslint');
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return 'typescript-eslint is not installed';
|
|
34
|
+
}
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Writes the overlay config to a scratch directory and returns its path. It is an ES module that
|
|
39
|
+
* imports the repository's config by absolute path, so it is never written into the repository.
|
|
40
|
+
*/
|
|
41
|
+
export function writeOverlayConfig(cwd) {
|
|
42
|
+
const base = CONFIG_FILES.find(name => fs.existsSync(path.join(cwd, name)));
|
|
43
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-lint-overlay-'));
|
|
44
|
+
const file = path.join(dir, 'eslint.overlay.config.mjs');
|
|
45
|
+
fs.writeFileSync(file, `import path from 'node:path';
|
|
46
|
+
import { createRequire } from 'node:module';
|
|
47
|
+
const repo = ${JSON.stringify(cwd)};
|
|
48
|
+
const require = createRequire(path.join(repo, 'package.json'));
|
|
49
|
+
const base = (await import(${JSON.stringify(pathToFileURL(path.join(cwd, base)).href)})).default;
|
|
50
|
+
const ts = require('typescript-eslint');
|
|
51
|
+
const typed = ${JSON.stringify(TYPED_RULES, null, 4)};
|
|
52
|
+
export default [
|
|
53
|
+
...base,
|
|
54
|
+
{
|
|
55
|
+
files: ['**/*.ts', '**/*.mts', '**/*.cts', '**/*.js', '**/*.mjs', '**/*.svelte.ts', '**/*.svelte.js'],
|
|
56
|
+
ignores: ['**/*.d.ts'],
|
|
57
|
+
languageOptions: { parser: ts.parser, parserOptions: { projectService: { allowDefaultProject: ['*.mjs', 'scripts/*.mjs'] }, tsconfigRootDir: repo } },
|
|
58
|
+
rules: typed,
|
|
59
|
+
},
|
|
60
|
+
{ files: ['**/*.svelte'], rules: typed },
|
|
61
|
+
// A test fake deliberately covers cases the types call impossible.
|
|
62
|
+
{ files: ['**/*.test.ts', '**/*.spec.ts', '**/*.svelte.test.ts', 'tests/**', 'test/**', '**/__tests__/**'], rules: { '@typescript-eslint/no-unnecessary-condition': 'off', '@typescript-eslint/no-floating-promises': 'off' } },
|
|
63
|
+
];
|
|
64
|
+
`);
|
|
65
|
+
return file;
|
|
66
|
+
}
|
|
67
|
+
/** ESLint's JSON report, kept to messages on changed lines; the rest is counted. */
|
|
68
|
+
export function onChangedLines(json, cwd, changedLines) {
|
|
69
|
+
let results;
|
|
70
|
+
try {
|
|
71
|
+
results = JSON.parse(json);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return { error: `eslint printed no JSON: ${json.trim().slice(0, 200)}` };
|
|
75
|
+
}
|
|
76
|
+
const blocking = [];
|
|
77
|
+
let preexisting = 0;
|
|
78
|
+
for (const result of results) {
|
|
79
|
+
const file = path.relative(cwd, result.filePath).split(path.sep).join('/');
|
|
80
|
+
const lines = changedLines[file];
|
|
81
|
+
for (const message of result.messages) {
|
|
82
|
+
if (lines?.has(message.line))
|
|
83
|
+
blocking.push({ file, line: message.line, column: message.column, rule: message.ruleId ?? 'error', message: message.message });
|
|
84
|
+
else
|
|
85
|
+
preexisting++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { blocking, preexisting };
|
|
89
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quadratic copy: an accumulator copied whole on every step of a loop.
|
|
3
|
+
* `groups.set(k, [...(groups.get(k) ?? []), item])`, `acc = [...acc, item]`, or a reduce that
|
|
4
|
+
* returns `[...acc, item]` / `{ ...acc, [k]: v }` copies everything gathered so far each time, so
|
|
5
|
+
* n items cost O(n²). Pushing into the existing array (or assigning the key) is linear.
|
|
6
|
+
* Reported only on lines a change adds, from the syntax tree.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import ts from 'typescript';
|
|
11
|
+
const CODE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
12
|
+
const BUILD_OUTPUT = /(^|\/)(dist|build|out|coverage|\.next|[\w-]+-dist)\//;
|
|
13
|
+
export function loopCopyFailures(cwd, changedLines, config) {
|
|
14
|
+
if (!config.gates.change_sweep?.enabled)
|
|
15
|
+
return [];
|
|
16
|
+
const failures = [];
|
|
17
|
+
for (const [file, lines] of Object.entries(changedLines)) {
|
|
18
|
+
if (!CODE.test(file) || BUILD_OUTPUT.test(file) || lines.size === 0)
|
|
19
|
+
continue;
|
|
20
|
+
let source;
|
|
21
|
+
try {
|
|
22
|
+
const text = fs.readFileSync(path.join(cwd, file), 'utf8');
|
|
23
|
+
if (!text.includes('...'))
|
|
24
|
+
continue;
|
|
25
|
+
source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const visit = (node) => {
|
|
31
|
+
const copied = copiedAccumulator(node, source);
|
|
32
|
+
if (copied) {
|
|
33
|
+
const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
34
|
+
if (lines.has(line))
|
|
35
|
+
failures.push(quadratic(file, line, copied));
|
|
36
|
+
}
|
|
37
|
+
ts.forEachChild(node, visit);
|
|
38
|
+
};
|
|
39
|
+
visit(source);
|
|
40
|
+
}
|
|
41
|
+
return failures;
|
|
42
|
+
}
|
|
43
|
+
/** The accumulator a node copies whole, inside a loop or a reduce, or undefined. */
|
|
44
|
+
function copiedAccumulator(node, source) {
|
|
45
|
+
// m.set(k, [...(m.get(k) ?? []), v]) in a loop
|
|
46
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'set' && node.arguments.length === 2) {
|
|
47
|
+
const map = node.expression.expression.getText(source);
|
|
48
|
+
const value = node.arguments[1];
|
|
49
|
+
if (ts.isArrayLiteralExpression(value) && value.elements.some(e => ts.isSpreadElement(e) && e.expression.getText(source).replace(/\s+/g, '').startsWith(`(${map}.get(`))) {
|
|
50
|
+
return insideLoop(node) ? `${map}'s list` : undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// acc = [...acc, v] / acc = { ...acc, k: v } in a loop
|
|
54
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(node.left)) {
|
|
55
|
+
const name = node.left.text;
|
|
56
|
+
if (spreadsItself(node.right, name, source) && insideLoop(node))
|
|
57
|
+
return name;
|
|
58
|
+
}
|
|
59
|
+
// reduce((acc, v) => [...acc, v]) or { ...acc, [k]: v }
|
|
60
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'reduce') {
|
|
61
|
+
const reducer = node.arguments[0];
|
|
62
|
+
if (reducer && (ts.isArrowFunction(reducer) || ts.isFunctionExpression(reducer)) && reducer.parameters[0] && ts.isIdentifier(reducer.parameters[0].name)) {
|
|
63
|
+
const acc = reducer.parameters[0].name.text;
|
|
64
|
+
const results = ts.isBlock(reducer.body)
|
|
65
|
+
? collectReturns(reducer.body)
|
|
66
|
+
: [ts.isParenthesizedExpression(reducer.body) ? reducer.body.expression : reducer.body];
|
|
67
|
+
if (results.some(r => spreadsItself(r, acc, source)))
|
|
68
|
+
return acc;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
function spreadsItself(expr, name, source) {
|
|
74
|
+
const unwrapped = ts.isParenthesizedExpression(expr) ? expr.expression : expr;
|
|
75
|
+
if (ts.isArrayLiteralExpression(unwrapped))
|
|
76
|
+
return unwrapped.elements.some(e => ts.isSpreadElement(e) && e.expression.getText(source) === name);
|
|
77
|
+
if (ts.isObjectLiteralExpression(unwrapped))
|
|
78
|
+
return unwrapped.properties.some(p => ts.isSpreadAssignment(p) && p.expression.getText(source) === name);
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
function collectReturns(body) {
|
|
82
|
+
const found = [];
|
|
83
|
+
const walk = (node) => {
|
|
84
|
+
if (ts.isFunctionLike(node))
|
|
85
|
+
return;
|
|
86
|
+
if (ts.isReturnStatement(node) && node.expression)
|
|
87
|
+
found.push(node.expression);
|
|
88
|
+
ts.forEachChild(node, walk);
|
|
89
|
+
};
|
|
90
|
+
body.statements.forEach(walk);
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
/** A loop (or an array callback that runs per item) between the node and its function. */
|
|
94
|
+
function insideLoop(node) {
|
|
95
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
96
|
+
if (ts.isForStatement(current) || ts.isForOfStatement(current) || ts.isForInStatement(current) || ts.isWhileStatement(current) || ts.isDoStatement(current))
|
|
97
|
+
return true;
|
|
98
|
+
if (ts.isFunctionLike(current)) {
|
|
99
|
+
const call = current.parent;
|
|
100
|
+
return !!call && ts.isCallExpression(call) && ts.isPropertyAccessExpression(call.expression) && /^(forEach|map|flatMap)$/.test(call.expression.name.text);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
function quadratic(file, line, accumulator) {
|
|
106
|
+
return {
|
|
107
|
+
id: 'quadratic-copy',
|
|
108
|
+
title: 'Accumulator copied on every step',
|
|
109
|
+
details: `${accumulator} is copied whole on every step (spread into a new array or object), so n items cost O(n²) work and allocations.`,
|
|
110
|
+
severity: 'medium',
|
|
111
|
+
provenance: 'traditional',
|
|
112
|
+
files: [file],
|
|
113
|
+
line,
|
|
114
|
+
hint: 'Mutate the accumulator you already own: push onto the existing list (`(m.get(k) ?? m.set(k, []).get(k)!).push(v)`), or assign the key.',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -24,7 +24,11 @@ export function migrationOrderFailures(cwd, diff, source, config) {
|
|
|
24
24
|
const directory = path.posix.dirname(file);
|
|
25
25
|
if (!file.endsWith('.sql') || !micromatch.isMatch(directory, settings.dirs))
|
|
26
26
|
continue;
|
|
27
|
-
|
|
27
|
+
const files = byDirectory.get(directory);
|
|
28
|
+
if (files)
|
|
29
|
+
files.push(file);
|
|
30
|
+
else
|
|
31
|
+
byDirectory.set(directory, [file]);
|
|
28
32
|
}
|
|
29
33
|
const failures = [];
|
|
30
34
|
for (const [directory, added] of byDirectory) {
|
|
@@ -53,7 +53,11 @@ function referrersOf(cwd, added, files) {
|
|
|
53
53
|
const underFolder = new Map();
|
|
54
54
|
for (const file of added) {
|
|
55
55
|
for (let dir = path.posix.dirname(file); dir !== '.' && dir !== '/'; dir = path.posix.dirname(dir)) {
|
|
56
|
-
|
|
56
|
+
const files = underFolder.get(dir);
|
|
57
|
+
if (files)
|
|
58
|
+
files.push(file);
|
|
59
|
+
else
|
|
60
|
+
underFolder.set(dir, [file]);
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
const referrers = new Map(added.map(file => [file, new Set()]));
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Partial fix: a change names a broader condition (`const hasSaved = a.length > 0 || count > 0`)
|
|
3
|
+
* and uses it at some call sites, while the narrower condition it replaces (`count > 0`) is still
|
|
4
|
+
* tested on its own elsewhere in the same folder (the same route's other file, a sibling page).
|
|
5
|
+
* "Fixed" means every case, so the places still using the old form are named. Reported on the
|
|
6
|
+
* line that adds the named condition, since the leftovers are, by definition, lines it did not touch.
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import ts from 'typescript';
|
|
11
|
+
const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|svelte)$/;
|
|
12
|
+
const SKIPPED = /\.(test|spec)\.|\.d\.ts$/;
|
|
13
|
+
export function partialFixFailures(cwd, changedLines, config) {
|
|
14
|
+
if (!config.gates.change_sweep?.enabled)
|
|
15
|
+
return [];
|
|
16
|
+
const failures = [];
|
|
17
|
+
for (const [file, lines] of Object.entries(changedLines)) {
|
|
18
|
+
if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(file) || SKIPPED.test(file) || lines.size === 0)
|
|
19
|
+
continue;
|
|
20
|
+
const source = parse(cwd, file);
|
|
21
|
+
if (!source)
|
|
22
|
+
continue;
|
|
23
|
+
const visit = (node) => {
|
|
24
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isOr(node.initializer)) {
|
|
25
|
+
const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
26
|
+
if (lines.has(line)) {
|
|
27
|
+
const leftovers = narrowerElsewhere(cwd, file, line, parts(node.initializer, source), changedLines);
|
|
28
|
+
if (leftovers.length)
|
|
29
|
+
failures.push(partial(file, line, node.name.text, leftovers));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
ts.forEachChild(node, visit);
|
|
33
|
+
};
|
|
34
|
+
visit(source);
|
|
35
|
+
}
|
|
36
|
+
return failures;
|
|
37
|
+
}
|
|
38
|
+
function isOr(expr) {
|
|
39
|
+
const e = ts.isParenthesizedExpression(expr) ? expr.expression : expr;
|
|
40
|
+
return ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.BarBarToken;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The comparisons a broader condition ORs together. A comparison on a property (`x.count > 0`)
|
|
44
|
+
* matches the same property, operator and value on any receiver (`data.x.count > 0`).
|
|
45
|
+
*/
|
|
46
|
+
function parts(expr, source) {
|
|
47
|
+
const found = [];
|
|
48
|
+
const walk = (e) => {
|
|
49
|
+
const inner = ts.isParenthesizedExpression(e) ? e.expression : e;
|
|
50
|
+
if (ts.isBinaryExpression(inner) && inner.operatorToken.kind === ts.SyntaxKind.BarBarToken) {
|
|
51
|
+
walk(inner.left);
|
|
52
|
+
walk(inner.right);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (ts.isBinaryExpression(inner) && isComparison(inner.operatorToken.kind) && ts.isPropertyAccessExpression(inner.left) && isLiteral(inner.right)) {
|
|
56
|
+
// The receiver's last name and the property (`answers.length`, `snapshot.answeredCount`): specific
|
|
57
|
+
// enough that an unrelated `.length > 0` never matches, loose enough for `data.snapshot.answeredCount`.
|
|
58
|
+
const receiver = inner.left.expression;
|
|
59
|
+
const owner = ts.isIdentifier(receiver) ? receiver.text : ts.isPropertyAccessExpression(receiver) ? receiver.name.text : undefined;
|
|
60
|
+
if (!owner)
|
|
61
|
+
return;
|
|
62
|
+
const tail = `${owner}.${inner.left.name.text}`;
|
|
63
|
+
const operator = inner.operatorToken.getText(source);
|
|
64
|
+
const value = inner.right.getText(source);
|
|
65
|
+
found.push({
|
|
66
|
+
text: inner.getText(source),
|
|
67
|
+
pattern: new RegExp(`(?<![\\w$])${escape(tail)}\\s*${escape(operator)}\\s*${escape(value)}(?![\\w.])`),
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
walk(expr);
|
|
72
|
+
return found;
|
|
73
|
+
}
|
|
74
|
+
function isComparison(kind) {
|
|
75
|
+
return [ts.SyntaxKind.GreaterThanToken, ts.SyntaxKind.GreaterThanEqualsToken, ts.SyntaxKind.LessThanToken, ts.SyntaxKind.LessThanEqualsToken,
|
|
76
|
+
ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken].includes(kind);
|
|
77
|
+
}
|
|
78
|
+
function isLiteral(e) {
|
|
79
|
+
return ts.isNumericLiteral(e) || ts.isStringLiteral(e) || e.kind === ts.SyntaxKind.NullKeyword || e.kind === ts.SyntaxKind.TrueKeyword || e.kind === ts.SyntaxKind.FalseKeyword;
|
|
80
|
+
}
|
|
81
|
+
/** `file:line` of each remaining standalone use of a part, in the same folder, outside the broader condition. */
|
|
82
|
+
function narrowerElsewhere(cwd, file, declLine, found, changed) {
|
|
83
|
+
if (found.length < 2)
|
|
84
|
+
return [];
|
|
85
|
+
const dir = path.posix.dirname(file);
|
|
86
|
+
let siblings;
|
|
87
|
+
try {
|
|
88
|
+
siblings = fs.readdirSync(path.join(cwd, dir)).filter(name => CODE.test(name) && !SKIPPED.test(name)).map(name => path.posix.join(dir, name));
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const leftovers = [];
|
|
94
|
+
for (const sibling of siblings) {
|
|
95
|
+
let text;
|
|
96
|
+
try {
|
|
97
|
+
text = fs.readFileSync(path.join(cwd, sibling), 'utf8');
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
text.split('\n').forEach((content, index) => {
|
|
103
|
+
const line = index + 1;
|
|
104
|
+
if (sibling === file && line === declLine)
|
|
105
|
+
return;
|
|
106
|
+
// A line the change itself added is the author's current intent, not a leftover.
|
|
107
|
+
if (changed[sibling]?.has(line) && sibling === file)
|
|
108
|
+
return;
|
|
109
|
+
if (found.some(part => part.pattern.test(content)) && !found.every(part => part.pattern.test(content)))
|
|
110
|
+
leftovers.push(`${sibling}:${line}`);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
return leftovers;
|
|
114
|
+
}
|
|
115
|
+
function parse(cwd, file) {
|
|
116
|
+
try {
|
|
117
|
+
return ts.createSourceFile(file, fs.readFileSync(path.join(cwd, file), 'utf8'), ts.ScriptTarget.Latest, true);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function escape(text) {
|
|
124
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
125
|
+
}
|
|
126
|
+
function partial(file, line, name, leftovers) {
|
|
127
|
+
const listed = leftovers.slice(0, 5).map(l => `\`${l}\``).join(', ');
|
|
128
|
+
return {
|
|
129
|
+
id: 'partial-fix',
|
|
130
|
+
title: 'Partial fix',
|
|
131
|
+
details: `\`${name}\` widens a condition, but the narrower form is still tested on its own at ${listed}${leftovers.length > 5 ? ` and ${leftovers.length - 5} more` : ''}.`,
|
|
132
|
+
severity: 'medium',
|
|
133
|
+
provenance: 'traditional',
|
|
134
|
+
files: [file],
|
|
135
|
+
line,
|
|
136
|
+
hint: `Use \`${name}\` (or the same condition) at each of those places too, or say in a comment why the narrower test is right there.`,
|
|
137
|
+
};
|
|
138
|
+
}
|