@rigour-labs/core 6.6.2 → 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 +5 -5
- 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,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orphaned file: a code file the change adds that nothing imports or runs.
|
|
3
|
+
*
|
|
4
|
+
* An agent that tries one approach, then another, leaves the first behind; type checkers and
|
|
5
|
+
* linters only look inside files, so a whole unused module passes them. A reference is a path
|
|
6
|
+
* named in code, package.json, CI or config (docs only mention), resolved to a file: relative
|
|
7
|
+
* paths against the naming file, `$lib/` to `src/lib/`, anything else from the repository root
|
|
8
|
+
* as npm scripts and CI write them. A common basename elsewhere never keeps a file alive, and a
|
|
9
|
+
* folder of new files that only import each other is reported as a whole. Files a framework or
|
|
10
|
+
* runner finds by itself (routes, hooks, tests, migrations, config) are never reported.
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from 'child_process';
|
|
13
|
+
import fs from 'fs';
|
|
14
|
+
import path from 'path';
|
|
15
|
+
import micromatch from 'micromatch';
|
|
16
|
+
import { addedFiles } from './migration-order.js';
|
|
17
|
+
import { sourceStem } from './package-layout.js';
|
|
18
|
+
import { ownOutputs } from './unused-exports.js';
|
|
19
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
20
|
+
const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|svelte)$/;
|
|
21
|
+
const FOUND_BY_RUNNER = /(^|\/)\+[^/]+$|(^|\/)hooks(\.server|\.client)?\.(ts|js)$|^netlify\/functions\/|^src\/params\/|\.(test|spec|e2e)\.[a-z]+$|\.d\.ts$|^(tests?|e2e|__tests__|migrations|supabase)\/|\.config\.[a-z]+$|^\./;
|
|
22
|
+
/** Files that can run or import another: code, package.json, CI and config. */
|
|
23
|
+
const REFERRER = /\.(ts|tsx|js|jsx|mjs|cjs|svelte|json|ya?ml|toml|sh)$/;
|
|
24
|
+
/** Lockfiles and generated bundles name no source file and can be megabytes. */
|
|
25
|
+
const MAX_REFERRER_BYTES = 2 * 1024 * 1024;
|
|
26
|
+
const SPECIFIER = /['"`\s]((?:\.{1,2}\/|\$lib\/|[\w@-]+\/)[^'"`\s]*)/g;
|
|
27
|
+
/** A module as importers name it: `x.svelte.ts` is imported as `./x.svelte` or `./x.svelte.js`, `dir/index.ts` as `dir`. */
|
|
28
|
+
const withoutExtension = (file) => file.replace(/\.(ts|tsx|js|jsx|mjs|cjs)$/, '').replace(/\.svelte$/, '').replace(/\/index$/, '');
|
|
29
|
+
/** Build output committed to the repository: bundles, not sources anyone imports. */
|
|
30
|
+
const BUILD_OUTPUT = /(^|\/)(dist|build|out|coverage|\.next|[\w-]+-dist)\//;
|
|
31
|
+
export function orphanFileFailures(cwd, diff, config) {
|
|
32
|
+
const settings = config.gates.orphan_files;
|
|
33
|
+
if (!settings?.enabled)
|
|
34
|
+
return [];
|
|
35
|
+
const added = addedFiles(diff).filter(file => CODE.test(file) && !FOUND_BY_RUNNER.test(file) && !BUILD_OUTPUT.test(file) && !micromatch.isMatch(file, settings.allow));
|
|
36
|
+
if (added.length === 0)
|
|
37
|
+
return [];
|
|
38
|
+
const excluded = ownOutputs(config);
|
|
39
|
+
const files = repositoryFiles(cwd)?.filter(file => !excluded.some(own => file === own || file.startsWith(`${own}/`)));
|
|
40
|
+
if (!files)
|
|
41
|
+
return []; // git could not answer: say nothing rather than guess
|
|
42
|
+
const referrers = referrersOf(cwd, added, files);
|
|
43
|
+
return [...unreferenced(added, referrers)].map(orphan);
|
|
44
|
+
}
|
|
45
|
+
/** Tracked and new (untracked, not ignored) files. */
|
|
46
|
+
function repositoryFiles(cwd) {
|
|
47
|
+
const result = spawnSync('git', ['ls-files', '--cached', '--others', '--exclude-standard'], { cwd, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024 });
|
|
48
|
+
return result.status === 0 ? [...new Set(result.stdout.split('\n').filter(Boolean))] : undefined;
|
|
49
|
+
}
|
|
50
|
+
/** For each added file, the files that name it. */
|
|
51
|
+
function referrersOf(cwd, added, files) {
|
|
52
|
+
const targets = new Map(added.map(file => [withoutExtension(path.posix.normalize(file)), file]));
|
|
53
|
+
const underFolder = new Map();
|
|
54
|
+
for (const file of added) {
|
|
55
|
+
for (let dir = path.posix.dirname(file); dir !== '.' && dir !== '/'; dir = path.posix.dirname(dir)) {
|
|
56
|
+
underFolder.set(dir, [...(underFolder.get(dir) ?? []), file]);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const referrers = new Map(added.map(file => [file, new Set()]));
|
|
60
|
+
for (const referrer of files) {
|
|
61
|
+
if (!REFERRER.test(referrer))
|
|
62
|
+
continue;
|
|
63
|
+
let text;
|
|
64
|
+
try {
|
|
65
|
+
if (fs.statSync(path.join(cwd, referrer)).size > MAX_REFERRER_BYTES)
|
|
66
|
+
continue;
|
|
67
|
+
text = fs.readFileSync(path.join(cwd, referrer), 'utf8');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
continue; // deleted in the working tree
|
|
71
|
+
}
|
|
72
|
+
for (const [, specifier] of text.matchAll(SPECIFIER)) {
|
|
73
|
+
for (const candidate of resolve(referrer, specifier)) {
|
|
74
|
+
const named = withoutExtension(path.posix.normalize(candidate)).replace(/\/+$/, '');
|
|
75
|
+
const target = targets.get(named);
|
|
76
|
+
if (target && target !== referrer)
|
|
77
|
+
referrers.get(target).add(referrer);
|
|
78
|
+
// A folder named in code (a fixtures or plugins directory read at run time) reaches every file under it.
|
|
79
|
+
for (const file of underFolder.get(named) ?? [])
|
|
80
|
+
if (file !== referrer)
|
|
81
|
+
referrers.get(file).add(referrer);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return referrers;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The files a specifier can name: relative to the naming file; `$lib/` as `src/lib/`; any other
|
|
89
|
+
* path from the repository root (as npm scripts and CI write them) and from the naming file's
|
|
90
|
+
* folder (a package.json in a subfolder); a compiled path (`dist/bin.js`) as its source.
|
|
91
|
+
*/
|
|
92
|
+
function resolve(referrer, specifier) {
|
|
93
|
+
if (specifier.startsWith('.'))
|
|
94
|
+
return [path.posix.join(path.posix.dirname(referrer), specifier)];
|
|
95
|
+
if (specifier.startsWith('$lib/'))
|
|
96
|
+
return [`src/lib/${specifier.slice('$lib/'.length)}`];
|
|
97
|
+
const fromFolder = path.posix.join(path.posix.dirname(referrer), specifier);
|
|
98
|
+
return [specifier, fromFolder, sourceStem(specifier), path.posix.join(path.posix.dirname(referrer), sourceStem(specifier))];
|
|
99
|
+
}
|
|
100
|
+
/** Added files nothing outside the added set reaches, found by clearing suspects until nothing changes. */
|
|
101
|
+
function unreferenced(added, referrers) {
|
|
102
|
+
const suspects = new Set(added);
|
|
103
|
+
for (let cleared = true; cleared;) {
|
|
104
|
+
cleared = false;
|
|
105
|
+
for (const file of suspects) {
|
|
106
|
+
if ([...referrers.get(file)].some(referrer => !suspects.has(referrer))) {
|
|
107
|
+
suspects.delete(file);
|
|
108
|
+
cleared = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return suspects;
|
|
113
|
+
}
|
|
114
|
+
function orphan(file) {
|
|
115
|
+
return {
|
|
116
|
+
id: 'orphan-file',
|
|
117
|
+
title: 'Orphaned file',
|
|
118
|
+
details: `\`${file}\` is new, but nothing outside the change's new files imports or runs it.`,
|
|
119
|
+
severity: 'medium',
|
|
120
|
+
provenance: 'traditional',
|
|
121
|
+
files: [file],
|
|
122
|
+
line: 1,
|
|
123
|
+
hint: 'Delete it, or wire it in where it is meant to run. If a tool loads it by path, add it to gates.orphan_files.allow.',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** A path a package or script names, as the source file it was compiled from: `dist/bin.js` → `src/bin`. */
|
|
2
|
+
export declare function sourceStem(relative: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Whether `file` is part of a package's public surface: an entry point a package.json on its path
|
|
5
|
+
* names, or a module an entry re-exports whole (`export * from './hooks/index.js'`), followed down.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isPackageEntry(cwd: string, file: string): boolean;
|
|
8
|
+
/** Whether the TypeScript project around `file` emits declarations (declaration or composite, following extends). */
|
|
9
|
+
export declare function emitsDeclarations(cwd: string, file: string): boolean;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a package's own files say about a source file: whether it is the package's entry point
|
|
3
|
+
* (its exports are the public API, used outside the repository), and whether its project emits
|
|
4
|
+
* declarations (an exported type named in another export's signature must stay exported).
|
|
5
|
+
* Compiled paths in package.json (dist/, lib/, build/, out/) are mapped back to sources.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'fs';
|
|
8
|
+
import path from 'path';
|
|
9
|
+
const COMPILED_DIR = /^(dist|lib|build|out)\//;
|
|
10
|
+
const COMPILED_EXT = /\.(d\.ts|d\.mts|js|mjs|cjs)$/;
|
|
11
|
+
/** A path a package or script names, as the source file it was compiled from: `dist/bin.js` → `src/bin`. */
|
|
12
|
+
export function sourceStem(relative) {
|
|
13
|
+
return relative.replace(/^\.\//, '').replace(COMPILED_DIR, 'src/').replace(COMPILED_EXT, '').replace(/\.(ts|tsx|mts|cts)$/, '');
|
|
14
|
+
}
|
|
15
|
+
/** The source files a package.json points at (main, module, types, bin, exports), as repository-relative stems. */
|
|
16
|
+
function packageEntryStems(cwd, packageJson) {
|
|
17
|
+
let pkg;
|
|
18
|
+
try {
|
|
19
|
+
pkg = JSON.parse(fs.readFileSync(path.join(cwd, packageJson), 'utf8'));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const dir = path.posix.dirname(packageJson);
|
|
25
|
+
const targets = [];
|
|
26
|
+
const collect = (value) => {
|
|
27
|
+
if (typeof value === 'string')
|
|
28
|
+
targets.push(value);
|
|
29
|
+
else if (value && typeof value === 'object')
|
|
30
|
+
Object.values(value).forEach(collect);
|
|
31
|
+
};
|
|
32
|
+
[pkg.main, pkg.module, pkg.types, pkg.typings, pkg.exports, pkg.bin].forEach(collect);
|
|
33
|
+
return targets.filter(t => t.startsWith('./') || !t.startsWith('.')).map(t => path.posix.normalize(path.posix.join(dir, sourceStem(t))));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Whether `file` is part of a package's public surface: an entry point a package.json on its path
|
|
37
|
+
* names, or a module an entry re-exports whole (`export * from './hooks/index.js'`), followed down.
|
|
38
|
+
*/
|
|
39
|
+
export function isPackageEntry(cwd, file) {
|
|
40
|
+
const stem = file.replace(/\.(ts|tsx|mts|cts|js|jsx|mjs|svelte)$/, '');
|
|
41
|
+
for (let dir = path.posix.dirname(file);; dir = path.posix.dirname(dir)) {
|
|
42
|
+
const candidate = dir === '.' ? 'package.json' : `${dir}/package.json`;
|
|
43
|
+
if (fs.existsSync(path.join(cwd, candidate)) && publicStems(cwd, candidate).has(stem))
|
|
44
|
+
return true;
|
|
45
|
+
if (dir === '.' || dir === '/')
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const publicCache = new Map();
|
|
50
|
+
/** Entry stems plus every module they re-export whole, transitively. */
|
|
51
|
+
function publicStems(cwd, packageJson) {
|
|
52
|
+
const key = `${cwd}\0${packageJson}`;
|
|
53
|
+
const cached = publicCache.get(key);
|
|
54
|
+
if (cached)
|
|
55
|
+
return cached;
|
|
56
|
+
const stems = new Set();
|
|
57
|
+
const queue = packageEntryStems(cwd, packageJson);
|
|
58
|
+
while (queue.length) {
|
|
59
|
+
const stem = queue.shift();
|
|
60
|
+
if (stems.has(stem))
|
|
61
|
+
continue;
|
|
62
|
+
stems.add(stem);
|
|
63
|
+
const text = readSource(cwd, stem);
|
|
64
|
+
for (const match of text.matchAll(/export\s+\*\s+(?:as\s+\w+\s+)?from\s+['"](\.[^'"]+)['"]/g)) {
|
|
65
|
+
queue.push(path.posix.normalize(path.posix.join(path.posix.dirname(stem), match[1].replace(COMPILED_EXT, '').replace(/\.(ts|tsx|mts|cts)$/, ''))));
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
publicCache.set(key, stems);
|
|
69
|
+
return stems;
|
|
70
|
+
}
|
|
71
|
+
function readSource(cwd, stem) {
|
|
72
|
+
for (const ext of ['.ts', '.tsx', '.mts', '.js', '.mjs']) {
|
|
73
|
+
try {
|
|
74
|
+
return fs.readFileSync(path.join(cwd, stem + ext), 'utf8');
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// try the next extension
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
/** Whether the TypeScript project around `file` emits declarations (declaration or composite, following extends). */
|
|
83
|
+
export function emitsDeclarations(cwd, file) {
|
|
84
|
+
for (let dir = path.posix.dirname(file);; dir = path.posix.dirname(dir)) {
|
|
85
|
+
const tsconfig = path.join(cwd, dir === '.' ? 'tsconfig.json' : `${dir}/tsconfig.json`);
|
|
86
|
+
if (fs.existsSync(tsconfig))
|
|
87
|
+
return declarationsIn(tsconfig, 0);
|
|
88
|
+
if (dir === '.' || dir === '/')
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function declarationsIn(tsconfig, depth) {
|
|
93
|
+
let config;
|
|
94
|
+
try {
|
|
95
|
+
config = JSON.parse(fs.readFileSync(tsconfig, 'utf8').replace(/^\s*\/\/.*$/gm, '').replace(/,(\s*[}\]])/g, '$1'));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const options = config?.compilerOptions ?? {};
|
|
101
|
+
if (typeof options.declaration === 'boolean' || typeof options.composite === 'boolean')
|
|
102
|
+
return options.declaration === true || options.composite === true;
|
|
103
|
+
if (typeof config?.extends === 'string' && config.extends.startsWith('.') && depth < 5) {
|
|
104
|
+
const parent = path.resolve(path.dirname(tsconfig), config.extends.endsWith('.json') ? config.extends : `${config.extends}.json`);
|
|
105
|
+
return declarationsIn(parent, depth + 1);
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query shapes that cost production, on lines a change adds (query-builder chains such as
|
|
3
|
+
* PostgREST/Supabase, Knex, Drizzle):
|
|
4
|
+
*
|
|
5
|
+
* - offset-paging: `.range(from, to)` or `.offset(n)` inside a loop, or in a callback handed to a
|
|
6
|
+
* pager (the bounds are the callback's own parameters, so something calls it page after page). Each page re-reads and skips
|
|
7
|
+
* every row before it, so a sweep over N rows reads O(N²); rows inserted mid-sweep shift pages,
|
|
8
|
+
* so rows are skipped or read twice. Keyset paging (`.gt(id, last)` + order + limit) does not.
|
|
9
|
+
* - unbounded-window: a lower bound on a time column (`.gt`/`.gte`) taken from a window object
|
|
10
|
+
* (`window.from`, `range.start`) with no upper bound on the same column in the same query: the
|
|
11
|
+
* code has a window with an end and reads only half of it, so every run reads up to "now" and
|
|
12
|
+
* rows written while it runs land in two windows. A bare "since" (everything after an event) is
|
|
13
|
+
* a legitimate query and is not reported.
|
|
14
|
+
*
|
|
15
|
+
* Read from the syntax tree, so a comment or string never matches.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'fs';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import ts from 'typescript';
|
|
20
|
+
const CODE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
21
|
+
const SKIPPED = /\.(test|spec)\.|\.d\.ts$|(^|\/)(__tests__|tests?|e2e)\//;
|
|
22
|
+
const TIME_COLUMN = /(_at|_on|At|On|date|Date|time|Time|timestamp|Timestamp)$|^(at|since|until|created|updated)$/;
|
|
23
|
+
const LOWER = new Set(['gt', 'gte']);
|
|
24
|
+
const UPPER = new Set(['lt', 'lte']);
|
|
25
|
+
export function queryPatternFailures(cwd, changedLines, config) {
|
|
26
|
+
if (!config.gates.query_patterns?.enabled)
|
|
27
|
+
return [];
|
|
28
|
+
const failures = [];
|
|
29
|
+
for (const [file, lines] of Object.entries(changedLines)) {
|
|
30
|
+
if (!CODE.test(file) || SKIPPED.test(file) || lines.size === 0)
|
|
31
|
+
continue;
|
|
32
|
+
let text;
|
|
33
|
+
try {
|
|
34
|
+
text = fs.readFileSync(path.join(cwd, file), 'utf8');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!/\.(range|offset|gte?)\(/.test(text))
|
|
40
|
+
continue;
|
|
41
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
42
|
+
const lineOf = (node) => source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1;
|
|
43
|
+
const visit = (node) => {
|
|
44
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
45
|
+
const method = node.expression.name.text;
|
|
46
|
+
const line = lineOf(node.expression.name);
|
|
47
|
+
if (lines.has(line)) {
|
|
48
|
+
if (isOffsetPage(method, node) && (insideLoop(node) || pagerCallback(node)))
|
|
49
|
+
failures.push(offsetPaging(file, line, method));
|
|
50
|
+
if (LOWER.has(method)) {
|
|
51
|
+
const column = columnOf(node, source);
|
|
52
|
+
if (column && TIME_COLUMN.test(column.replace(/['"`]/g, '').split('.').pop() ?? '') && fromWindow(node) && !hasUpperBound(node, column, source)) {
|
|
53
|
+
failures.push(unboundedWindow(file, line, column));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
ts.forEachChild(node, visit);
|
|
59
|
+
};
|
|
60
|
+
visit(source);
|
|
61
|
+
}
|
|
62
|
+
return failures;
|
|
63
|
+
}
|
|
64
|
+
function isOffsetPage(method, call) {
|
|
65
|
+
return (method === 'range' && call.arguments.length === 2) || (method === 'offset' && call.arguments.length === 1);
|
|
66
|
+
}
|
|
67
|
+
/** A loop between the call and the function it is in. */
|
|
68
|
+
function insideLoop(node) {
|
|
69
|
+
for (let current = node.parent; current; current = current.parent) {
|
|
70
|
+
if (ts.isForStatement(current) || ts.isForOfStatement(current) || ts.isForInStatement(current) || ts.isWhileStatement(current) || ts.isDoStatement(current))
|
|
71
|
+
return true;
|
|
72
|
+
if (ts.isFunctionLike(current))
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The offset comes from a parameter of a function passed as an argument (`readAllPages((from, to) =>
|
|
79
|
+
* q.range(from, to))`): a pager calls it once per page. A named function taking bounds from a
|
|
80
|
+
* request (UI pagination, one page per call) is not this.
|
|
81
|
+
*/
|
|
82
|
+
function pagerCallback(call) {
|
|
83
|
+
let fn = call.parent;
|
|
84
|
+
while (fn && !ts.isFunctionLike(fn))
|
|
85
|
+
fn = fn.parent;
|
|
86
|
+
if (!fn || !(ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) || !ts.isCallExpression(fn.parent) || !fn.parent.arguments.includes(fn))
|
|
87
|
+
return false;
|
|
88
|
+
const params = new Set(fn.parameters.map(p => (ts.isIdentifier(p.name) ? p.name.text : '')).filter(Boolean));
|
|
89
|
+
const offset = call.arguments[0];
|
|
90
|
+
return !!offset && ts.isIdentifier(offset) && params.has(offset.text);
|
|
91
|
+
}
|
|
92
|
+
/** The lower bound is one end of a window object: `window.from`, `range.start`, `span.begin`. */
|
|
93
|
+
function fromWindow(call) {
|
|
94
|
+
const bound = call.arguments[1];
|
|
95
|
+
return !!bound && ts.isPropertyAccessExpression(bound) && /^(from|start|begin|after|lower)$/.test(bound.name.text);
|
|
96
|
+
}
|
|
97
|
+
function columnOf(call, source) {
|
|
98
|
+
const first = call.arguments[0];
|
|
99
|
+
return first ? first.getText(source) : undefined;
|
|
100
|
+
}
|
|
101
|
+
/** The whole builder chain this call belongs to: every `.method(args)` from its root to its outermost call. */
|
|
102
|
+
function chainCalls(call) {
|
|
103
|
+
let top = call;
|
|
104
|
+
while (top.parent && (ts.isPropertyAccessExpression(top.parent) || (ts.isCallExpression(top.parent) && top.parent.expression === top) || ts.isAwaitExpression(top.parent) || ts.isNonNullExpression(top.parent))) {
|
|
105
|
+
top = top.parent;
|
|
106
|
+
}
|
|
107
|
+
const calls = [];
|
|
108
|
+
const walk = (node) => {
|
|
109
|
+
if (ts.isCallExpression(node))
|
|
110
|
+
calls.push(node);
|
|
111
|
+
if (ts.isCallExpression(node) || ts.isPropertyAccessExpression(node) || ts.isAwaitExpression(node) || ts.isNonNullExpression(node)) {
|
|
112
|
+
walk(ts.isCallExpression(node) ? node.expression : node.expression);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
walk(top);
|
|
116
|
+
return calls;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* An upper bound on the same column in the same query. A chain built in steps (`let q = …; q = q.lte(…)`)
|
|
120
|
+
* or a bound added through a variable cannot be followed, so a query assembled outside one
|
|
121
|
+
* expression is given the benefit of the doubt: only a single self-contained chain is reported.
|
|
122
|
+
*/
|
|
123
|
+
function hasUpperBound(call, column, source) {
|
|
124
|
+
const calls = chainCalls(call);
|
|
125
|
+
const rooted = calls.some(c => ts.isPropertyAccessExpression(c.expression) && /^(from|select|table|selectFrom)$/.test(c.expression.name.text));
|
|
126
|
+
if (!rooted)
|
|
127
|
+
return true;
|
|
128
|
+
return calls.some(c => ts.isPropertyAccessExpression(c.expression) && UPPER.has(c.expression.name.text) && c.arguments[0]?.getText(source) === column);
|
|
129
|
+
}
|
|
130
|
+
function offsetPaging(file, line, method) {
|
|
131
|
+
return {
|
|
132
|
+
id: 'offset-paging',
|
|
133
|
+
title: 'Offset paging in a loop',
|
|
134
|
+
details: `\`.${method}()\` pages by offset inside a loop: every page re-reads the rows before it, and rows written during the sweep shift the pages, so some are skipped or read twice.`,
|
|
135
|
+
severity: 'medium',
|
|
136
|
+
provenance: 'traditional',
|
|
137
|
+
files: [file],
|
|
138
|
+
line,
|
|
139
|
+
hint: 'Page by key instead: order by a unique column and read the next page with `.gt(column, lastSeen)` and a limit.',
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function unboundedWindow(file, line, column) {
|
|
143
|
+
return {
|
|
144
|
+
id: 'unbounded-window',
|
|
145
|
+
title: 'Time window with no upper bound',
|
|
146
|
+
details: `The query reads ${column} from a lower bound with no upper bound: each run reads up to "now", and rows written while it runs fall into two windows.`,
|
|
147
|
+
severity: 'medium',
|
|
148
|
+
provenance: 'traditional',
|
|
149
|
+
files: [file],
|
|
150
|
+
line,
|
|
151
|
+
hint: `Bound both ends: add \`.lte(${column}, until)\` with the run's own "until".`,
|
|
152
|
+
};
|
|
153
|
+
}
|
package/dist/review/quiet.js
CHANGED
|
@@ -20,7 +20,7 @@ import fs from 'fs';
|
|
|
20
20
|
import path from 'path';
|
|
21
21
|
import { checkId, isMuted, readOutcomes, recordOutcome, reportedCheck } from './check-outcomes.js';
|
|
22
22
|
import { readStateFile } from './trusted-state.js';
|
|
23
|
-
const PROVEN_GATES = new Set(['semantic-bugs', 'hallucinated-imports', 'security-patterns', 'deep-analysis', 'diff-tests']);
|
|
23
|
+
const PROVEN_GATES = new Set(['semantic-bugs', 'hallucinated-imports', 'security-patterns', 'deep-analysis', 'diff-tests', 'unused-export', 'orphan-file', 'offset-paging', 'unbounded-window', 'duplicate-function']);
|
|
24
24
|
export const DISMISSED_FILE = path.join('.rigour', 'dismissed.json');
|
|
25
25
|
export function isProven(failure) {
|
|
26
26
|
return PROVEN_GATES.has(failure.id);
|
package/dist/review/review.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ export interface ReviewResult {
|
|
|
30
30
|
dismissedByGate: Record<string, number>;
|
|
31
31
|
unlocated: number;
|
|
32
32
|
excludedOutsideChangedLines: number;
|
|
33
|
+
/** Findings the base already had: counted, not reported (baseline.ts). */
|
|
34
|
+
preexisting: number;
|
|
33
35
|
changedLines: Record<string, Set<number>>;
|
|
34
36
|
report: Report | null;
|
|
35
37
|
deepError?: string;
|
package/dist/review/review.js
CHANGED
|
@@ -19,19 +19,28 @@ import { checkId, rememberReported } from './check-outcomes.js';
|
|
|
19
19
|
import { diffFromGit } from './git-diff.js';
|
|
20
20
|
import { diffTestFailures } from './diff-test-findings.js';
|
|
21
21
|
import { migrationOrderFailures } from './migration-order.js';
|
|
22
|
+
import { orphanFileFailures } from './orphan-files.js';
|
|
23
|
+
import { unusedExportFailures } from './unused-exports.js';
|
|
24
|
+
import { queryPatternFailures } from './query-patterns.js';
|
|
25
|
+
import { optionalParamFailures } from './optional-params.js';
|
|
26
|
+
import { duplicateFunctionFailures } from './duplicate-functions.js';
|
|
22
27
|
import { isControlFile } from './trusted-state.js';
|
|
28
|
+
import { baseCommit, baseFindings, splitIntroduced } from './baseline.js';
|
|
23
29
|
export async function reviewChange(input) {
|
|
24
30
|
const diff = input.diff ?? diffFromGit(input.cwd, input.source);
|
|
25
31
|
const changedLines = withoutGenerated(input.cwd, parseDiff(diff));
|
|
26
32
|
const targets = input.files?.length ? input.files : Object.keys(changedLines);
|
|
27
33
|
if (targets.length === 0) {
|
|
28
|
-
return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null, gateErrors: [], controlFilesChanged: controlFiles(diff) };
|
|
34
|
+
return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], muted: 0, dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, preexisting: 0, changedLines, report: null, gateErrors: [], controlFilesChanged: controlFiles(diff) };
|
|
29
35
|
}
|
|
30
36
|
const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff), diff } : undefined;
|
|
31
37
|
const report = await new GateRunner(input.config).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
|
|
38
|
+
const preexisting = await dropPreexisting(input, report, targets);
|
|
32
39
|
if (input.diffTests && deep)
|
|
33
40
|
report.failures.push(...await diffTestFailures(input.cwd, input.source, deep));
|
|
34
41
|
report.failures.push(...migrationOrderFailures(input.cwd, diff, input.source, input.config));
|
|
42
|
+
report.failures.push(...unusedExportFailures(input.cwd, diff, input.config), ...orphanFileFailures(input.cwd, diff, input.config));
|
|
43
|
+
report.failures.push(...queryPatternFailures(input.cwd, changedLines, input.config), ...optionalParamFailures(input.cwd, changedLines, input.config), ...duplicateFunctionFailures(input.cwd, changedLines, input.config));
|
|
35
44
|
const split = splitByChangedLines(report.failures, changedLines, deep ? changedFunctionSpans(input.cwd, changedLines) : {}, removedByFile(diff));
|
|
36
45
|
const deepError = deepAnalysisError(report);
|
|
37
46
|
const quiet = quietSplit(input.cwd, split.findings, input.config.review?.include_heuristics, input.trustedRef);
|
|
@@ -49,6 +58,7 @@ export async function reviewChange(input) {
|
|
|
49
58
|
contextFindings: split.contextFindings,
|
|
50
59
|
unlocated: split.unlocated,
|
|
51
60
|
excludedOutsideChangedLines: split.outside,
|
|
61
|
+
preexisting,
|
|
52
62
|
changedLines,
|
|
53
63
|
report,
|
|
54
64
|
gateErrors,
|
|
@@ -56,6 +66,24 @@ export async function reviewChange(input) {
|
|
|
56
66
|
...(deepError ? { deepError } : {}),
|
|
57
67
|
};
|
|
58
68
|
}
|
|
69
|
+
/** Drop the rules' findings the base already had; returns how many. Model findings stay: the model reviews only the change. */
|
|
70
|
+
async function dropPreexisting(input, report, targets) {
|
|
71
|
+
if (input.config.review?.show_preexisting)
|
|
72
|
+
return 0;
|
|
73
|
+
const commit = baseCommit(input.cwd, input.source ?? (input.diff ? undefined : { mode: 'working' }));
|
|
74
|
+
const rules = report.failures.filter(f => f.provenance !== 'deep-analysis');
|
|
75
|
+
if (!commit || rules.length === 0)
|
|
76
|
+
return 0;
|
|
77
|
+
try {
|
|
78
|
+
const { preexisting } = splitIntroduced(rules, await baseFindings(input.cwd, input.config, commit, targets));
|
|
79
|
+
const old = new Set(preexisting);
|
|
80
|
+
report.failures = report.failures.filter(f => !old.has(f));
|
|
81
|
+
return old.size;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return 0; // the comparison is a courtesy; it never fails a review
|
|
85
|
+
}
|
|
86
|
+
}
|
|
59
87
|
/** Every path the diff touches (including deletions, which parseDiff drops) that steers Rigour itself. */
|
|
60
88
|
function controlFiles(diff) {
|
|
61
89
|
const files = new Set();
|
|
@@ -11,6 +11,8 @@ import { anchorInChangedFunction, changedFunctionSpans } from './changed-functio
|
|
|
11
11
|
import { diffFromGit } from './git-diff.js';
|
|
12
12
|
import { reviewChange, toReviewFinding } from './review.js';
|
|
13
13
|
import { findingKey } from './quiet.js';
|
|
14
|
+
/** These tests are about other checks; a new unreferenced fixture file is not their subject. */
|
|
15
|
+
const NO_DEAD_CODE = { unused_exports: { enabled: false }, orphan_files: { enabled: false } };
|
|
14
16
|
const LEAKY = [
|
|
15
17
|
'export async function notify(endpoint: string, signature: string) {',
|
|
16
18
|
" return fetch(endpoint, { method: 'POST', headers: { 'x-hook-signature': signature } });",
|
|
@@ -115,6 +117,30 @@ describe('git-backed review', () => {
|
|
|
115
117
|
expect(semantic.map(f => [f.files?.[0], f.line])).toEqual([['src/notify.ts', 2]]);
|
|
116
118
|
expect(toReviewFinding(semantic[0])).toMatchObject({ id: 'semantic-bugs', file: 'src/notify.ts', line: 2, severity: 'high' });
|
|
117
119
|
});
|
|
120
|
+
it('reports what the change introduced, not what the touched code already had', async () => {
|
|
121
|
+
const branchy = (name, extra = '') => [
|
|
122
|
+
`export function ${name}(x: number) {`,
|
|
123
|
+
...Array.from({ length: 12 }, (_, i) => ` if (x === ${i}) return ${i};`),
|
|
124
|
+
extra,
|
|
125
|
+
' return -1;',
|
|
126
|
+
'}',
|
|
127
|
+
'',
|
|
128
|
+
].join('\n');
|
|
129
|
+
write('src/old.ts', branchy('legacy'));
|
|
130
|
+
git('add', '-A');
|
|
131
|
+
git('commit', '-qm', 'init');
|
|
132
|
+
write('src/old.ts', branchy('legacy', ' if (x === 99) return 99;'));
|
|
133
|
+
write('src/fresh.ts', branchy('fresh'));
|
|
134
|
+
const config = ConfigSchema.parse({ version: 1 });
|
|
135
|
+
const result = await reviewChange({ cwd: repo, config });
|
|
136
|
+
const complexity = [...result.findings, ...result.fileFindings, ...result.advisory].filter(f => f.id === 'AST_COMPLEXITY');
|
|
137
|
+
expect(complexity.map(f => f.files?.[0])).toEqual(['src/fresh.ts']);
|
|
138
|
+
expect(result.preexisting).toBeGreaterThanOrEqual(1);
|
|
139
|
+
const all = await reviewChange({ cwd: repo, config: ConfigSchema.parse({ version: 1, review: { show_preexisting: true } }) });
|
|
140
|
+
const shown = [...all.findings, ...all.fileFindings, ...all.advisory].filter(f => f.id === 'AST_COMPLEXITY');
|
|
141
|
+
expect(shown.map(f => f.files?.[0]).sort()).toEqual(['src/fresh.ts', 'src/old.ts']);
|
|
142
|
+
expect(all.preexisting).toBe(0);
|
|
143
|
+
});
|
|
118
144
|
it('lets a change dismiss its own finding only when the review trusts the working tree', async () => {
|
|
119
145
|
write('src/old.ts', 'export const a = 1;\n');
|
|
120
146
|
write('.gitignore', '.rigour/*\n!.rigour/dismissed.json\n');
|
|
@@ -122,7 +148,7 @@ describe('git-backed review', () => {
|
|
|
122
148
|
git('commit', '-qm', 'init');
|
|
123
149
|
git('checkout', '-q', '-b', 'pr');
|
|
124
150
|
write('src/notify.ts', LEAKY);
|
|
125
|
-
const config = ConfigSchema.parse({ version: 1, gates: { semantic_bugs: { enabled: true } } });
|
|
151
|
+
const config = ConfigSchema.parse({ version: 1, gates: { semantic_bugs: { enabled: true }, ...NO_DEAD_CODE } });
|
|
126
152
|
const key = findingKey((await reviewChange({ cwd: repo, config })).findings[0]);
|
|
127
153
|
write('.rigour/dismissed.json', JSON.stringify({ version: 1, entries: [{ key, reason: 'trust me', at: 'now' }] }));
|
|
128
154
|
git('add', '-A');
|
|
@@ -147,7 +173,7 @@ describe('git-backed review', () => {
|
|
|
147
173
|
});
|
|
148
174
|
it('is ERROR when a proven gate crashed, and only lists a crashed heuristic gate', async () => {
|
|
149
175
|
write('src/a.ts', 'export const a = 1;\n');
|
|
150
|
-
const config = ConfigSchema.parse({ version: 1 });
|
|
176
|
+
const config = ConfigSchema.parse({ version: 1, gates: NO_DEAD_CODE });
|
|
151
177
|
const crash = (summary) => vi.spyOn(GateRunner.prototype, 'run').mockResolvedValueOnce({ status: 'FAIL', summary, failures: [], stats: { duration_ms: 0 } });
|
|
152
178
|
crash({ 'hallucinated-imports': 'ERROR' });
|
|
153
179
|
const proven = await reviewChange({ cwd: repo, config });
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Config } from '../types/index.js';
|
|
2
|
+
export interface PriorPoint {
|
|
3
|
+
point: string;
|
|
4
|
+
resolved: boolean;
|
|
5
|
+
evidence?: string;
|
|
6
|
+
/** As the human marked it: false for a nit, a question, or a point they said was optional. Open non-blocking points are listed for the reply, not held against the push. */
|
|
7
|
+
blocking?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface BlockingIssue {
|
|
10
|
+
file: string;
|
|
11
|
+
line?: number;
|
|
12
|
+
issue: string;
|
|
13
|
+
why?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ReviewerVerdict {
|
|
16
|
+
prior_points: PriorPoint[];
|
|
17
|
+
blocking: BlockingIssue[];
|
|
18
|
+
non_blocking: Array<{
|
|
19
|
+
file: string;
|
|
20
|
+
issue: string;
|
|
21
|
+
}>;
|
|
22
|
+
}
|
|
23
|
+
export interface ReviewerResult {
|
|
24
|
+
verdict?: ReviewerVerdict;
|
|
25
|
+
/** Why there is no verdict: the reviewer could not run or answered badly. Never a pass. */
|
|
26
|
+
error?: string;
|
|
27
|
+
/** The human review it worked from, if any (`Reviewer: <login>, <date>`). */
|
|
28
|
+
previousReview?: string;
|
|
29
|
+
cached: boolean;
|
|
30
|
+
}
|
|
31
|
+
/** How commands are run; tests replace it. */
|
|
32
|
+
export type Exec = (command: string, args: string[], options: {
|
|
33
|
+
cwd: string;
|
|
34
|
+
timeoutMs: number;
|
|
35
|
+
env?: Record<string, string>;
|
|
36
|
+
}) => Promise<{
|
|
37
|
+
exitCode: number;
|
|
38
|
+
stdout: string;
|
|
39
|
+
stderr: string;
|
|
40
|
+
}>;
|
|
41
|
+
/** Called while the reviewer works, so a slow run and a stuck one look different. */
|
|
42
|
+
export type Progress = (message: string) => void;
|
|
43
|
+
export declare function runReviewer(cwd: string, base: string, config: Config, exec?: Exec, progress?: Progress): Promise<ReviewerResult>;
|
|
44
|
+
/** Open blocking points and new blocking issues: what stops a push. An open non-blocking point is answered in the reply, not held against the push. */
|
|
45
|
+
export declare function reviewerBlocks(result: ReviewerResult): boolean;
|
|
46
|
+
/** The reviewer's answer as a verdict, or why it is not one. */
|
|
47
|
+
export declare function parseVerdict(answer: {
|
|
48
|
+
exitCode: number;
|
|
49
|
+
stdout: string;
|
|
50
|
+
stderr: string;
|
|
51
|
+
}, hadReview: boolean): {
|
|
52
|
+
verdict: ReviewerVerdict;
|
|
53
|
+
} | {
|
|
54
|
+
error: string;
|
|
55
|
+
};
|