@rigour-labs/core 6.6.2 → 6.6.4
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 +37 -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,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unused export: an export the change adds that no other file names.
|
|
3
|
+
*
|
|
4
|
+
* Linters skip exports (no-unused-vars only sees module scope) and type checkers accept them, so
|
|
5
|
+
* dead exports pile up in agent-written code. Only exports on added lines are checked. Another
|
|
6
|
+
* file uses an export when it names the symbol as a whole word AND points at the module: an import,
|
|
7
|
+
* re-export, dynamic import or mock whose specifier ends in the module's name (its folder's, for an
|
|
8
|
+
* index file). A same-named word elsewhere, such as an unrelated route parameter, is not a use. What a framework calls by convention
|
|
9
|
+
* (SvelteKit and Next.js route modules, hooks, serverless functions) is never reported.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from 'child_process';
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { emitsDeclarations, isPackageEntry } from './package-layout.js';
|
|
15
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
16
|
+
const CODE = /\.(ts|tsx|js|jsx|mjs|svelte)$/;
|
|
17
|
+
const SKIPPED = /\.(test|spec)\.|\.d\.ts$|(^|\/)(dist|build|out|coverage|\.next|[\w-]+-dist)\//;
|
|
18
|
+
/** What a route or hook module exports for its framework, not for an importer. */
|
|
19
|
+
const FRAMEWORK_EXPORTS = new Set([
|
|
20
|
+
'load', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS', 'HEAD', 'fallback', 'actions',
|
|
21
|
+
'prerender', 'ssr', 'csr', 'trailingSlash', 'config', 'entries', 'handle', 'handleFetch',
|
|
22
|
+
'handleError', 'reroute', 'transport', 'init', 'match', 'default',
|
|
23
|
+
'metadata', 'generateMetadata', 'generateStaticParams', 'generateViewport', 'viewport',
|
|
24
|
+
'dynamic', 'dynamicParams', 'revalidate', 'fetchCache', 'runtime', 'preferredRegion',
|
|
25
|
+
'maxDuration', 'middleware', 'proxy',
|
|
26
|
+
]);
|
|
27
|
+
function isRouteModule(file) {
|
|
28
|
+
return /(^|\/)\+(page|layout|server|error)(\.server)?\.(ts|js)$/.test(file)
|
|
29
|
+
|| /(^|\/)hooks(\.server|\.client)?\.(ts|js)$/.test(file)
|
|
30
|
+
|| /(^|\/)params\/[^/]+\.(ts|js)$/.test(file)
|
|
31
|
+
|| /^netlify\/functions\//.test(file)
|
|
32
|
+
|| /(^|\/)(page|layout|route|loading|error|not-found|template|default|middleware|proxy)\.(ts|tsx|js|jsx)$/.test(file);
|
|
33
|
+
}
|
|
34
|
+
const DECLARATION = /^\s*export\s+(?:declare\s+)?(?:async\s+)?(const|let|var|function\*?|class|type|interface|enum|abstract\s+class)\s+([A-Za-z_$][\w$]*)/;
|
|
35
|
+
const LIST = /^\s*export\s+(?:type\s+)?\{([^}]*)\}\s*(?:;|$)/;
|
|
36
|
+
const RE_EXPORT = /^\s*export\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/;
|
|
37
|
+
/** Exports on the diff's added lines, with their line in the new file. */
|
|
38
|
+
function addedExports(diff) {
|
|
39
|
+
const found = [];
|
|
40
|
+
let file;
|
|
41
|
+
let line = 0;
|
|
42
|
+
let previous = '';
|
|
43
|
+
for (const text of diff.split('\n')) {
|
|
44
|
+
const header = text.startsWith('+++ ') && previous.startsWith('--- ');
|
|
45
|
+
previous = text;
|
|
46
|
+
if (header) {
|
|
47
|
+
file = text.startsWith('+++ b/') ? text.slice('+++ b/'.length) : undefined;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (text.startsWith('diff --git ')) {
|
|
51
|
+
file = undefined;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const hunk = text.match(/^@@ -\d+(?:,\d+)? \+(\d+)/);
|
|
55
|
+
if (hunk) {
|
|
56
|
+
line = Number(hunk[1]);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (!file || text.startsWith('-'))
|
|
60
|
+
continue;
|
|
61
|
+
if (text.startsWith('+')) {
|
|
62
|
+
if (CODE.test(file) && !SKIPPED.test(file))
|
|
63
|
+
found.push(...exportsOn(text.slice(1), file, line));
|
|
64
|
+
line++;
|
|
65
|
+
}
|
|
66
|
+
else if (text.startsWith(' ')) {
|
|
67
|
+
line++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return found;
|
|
71
|
+
}
|
|
72
|
+
function exportsOn(text, file, line) {
|
|
73
|
+
const declaration = text.match(DECLARATION);
|
|
74
|
+
if (declaration)
|
|
75
|
+
return [{ file, name: declaration[2], line, ...(/^(type|interface)$/.test(declaration[1]) ? { type: true } : {}) }];
|
|
76
|
+
const reExport = text.match(RE_EXPORT);
|
|
77
|
+
if (reExport) {
|
|
78
|
+
const source = reExport[2].startsWith('.') ? path.posix.join(path.posix.dirname(file), reExport[2]) : undefined;
|
|
79
|
+
return source ? names(reExport[1]).map(name => ({ file, name, line, source })) : []; // a package's export is that package's to judge
|
|
80
|
+
}
|
|
81
|
+
const list = text.match(LIST);
|
|
82
|
+
if (!list || /\bfrom\b/.test(text))
|
|
83
|
+
return [];
|
|
84
|
+
return names(list[1]).map(name => ({ file, name, line }));
|
|
85
|
+
}
|
|
86
|
+
/** The exported names in `a, b as c, type D`: what importers would write. */
|
|
87
|
+
function names(list) {
|
|
88
|
+
return list.split(',')
|
|
89
|
+
.map(part => part.trim().replace(/^type\s+/, '').split(/\s+as\s+/).pop()?.trim())
|
|
90
|
+
.filter((name) => !!name && /^[A-Za-z_$][\w$]*$/.test(name));
|
|
91
|
+
}
|
|
92
|
+
const withoutExtension = (file) => path.posix.normalize(file).replace(/\.(ts|tsx|js|jsx|mjs|cjs|svelte)$/, '').replace(/\/index$/, '');
|
|
93
|
+
/** A file that names the export without using it: the exporting file itself, and a re-export's source module. */
|
|
94
|
+
function isOwnFile(user, exp) {
|
|
95
|
+
const normalized = path.posix.normalize(user);
|
|
96
|
+
return normalized === path.posix.normalize(exp.file) || (!!exp.source && withoutExtension(normalized) === withoutExtension(exp.source));
|
|
97
|
+
}
|
|
98
|
+
export function unusedExportFailures(cwd, diff, config) {
|
|
99
|
+
const settings = config.gates.unused_exports;
|
|
100
|
+
if (!settings?.enabled)
|
|
101
|
+
return [];
|
|
102
|
+
const allowed = new Set(settings.allow);
|
|
103
|
+
const candidates = addedExports(diff).filter(exp => !allowed.has(exp.name) && !(isRouteModule(exp.file) && FRAMEWORK_EXPORTS.has(exp.name))
|
|
104
|
+
&& !isPackageEntry(cwd, exp.file) && !signatureType(cwd, exp));
|
|
105
|
+
if (candidates.length === 0)
|
|
106
|
+
return [];
|
|
107
|
+
const users = filesNaming(cwd, [...new Set(candidates.map(exp => exp.name))], ownOutputs(config));
|
|
108
|
+
if (!users)
|
|
109
|
+
return []; // git could not answer: say nothing rather than guess
|
|
110
|
+
const points = pointsAtCache(cwd);
|
|
111
|
+
return candidates
|
|
112
|
+
.filter(exp => ![...(users.get(exp.name) ?? [])].some(user => !isOwnFile(user, exp) && points(user, exp.file)))
|
|
113
|
+
.map(unused);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* In a project that emits declarations, an exported type named elsewhere in its own file is part
|
|
117
|
+
* of another export's signature (TypeScript requires it to stay exported); in an application it is not.
|
|
118
|
+
*/
|
|
119
|
+
function signatureType(cwd, exp) {
|
|
120
|
+
if (!exp.type || !emitsDeclarations(cwd, exp.file))
|
|
121
|
+
return false;
|
|
122
|
+
try {
|
|
123
|
+
const pattern = new RegExp(`\\b${exp.name.replace(/\$/g, '\\$')}\\b`, 'g');
|
|
124
|
+
return (fs.readFileSync(path.join(cwd, exp.file), 'utf8').match(pattern)?.length ?? 0) > 1;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/** Whether a file names `module` in a specifier: `from './dir/module'`, `import('../module.js')`, `vi.mock('$lib/module')`. */
|
|
131
|
+
function pointsAtCache(cwd) {
|
|
132
|
+
const texts = new Map();
|
|
133
|
+
return (file, module) => {
|
|
134
|
+
const ext = path.posix.extname(module);
|
|
135
|
+
const stem = path.posix.basename(module, ext);
|
|
136
|
+
const names = stem === 'index' ? [path.posix.basename(path.posix.dirname(module)), 'index'] : [stem];
|
|
137
|
+
const escaped = names.map(n => n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
|
|
138
|
+
const specifier = new RegExp(`['"\`][^'"\`]*/(?:${escaped})(\\.(ts|tsx|js|jsx|mjs|svelte))?['"\`]`);
|
|
139
|
+
if (!texts.has(file)) {
|
|
140
|
+
try {
|
|
141
|
+
texts.set(file, fs.readFileSync(path.join(cwd, file), 'utf8'));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
texts.set(file, '');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return specifier.test(texts.get(file));
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** Rigour's own reports name exports and files; they must never count as a use. */
|
|
151
|
+
export function ownOutputs(config) {
|
|
152
|
+
return ['.rigour', config.output?.report_path ?? 'rigour-report.json', 'rigour-fix-packet.json'];
|
|
153
|
+
}
|
|
154
|
+
/** Names per batch: one git grep over the repository answers them all. */
|
|
155
|
+
const NAMES_PER_SEARCH = 200;
|
|
156
|
+
/**
|
|
157
|
+
* For each name, the tracked and new (untracked, not ignored) files containing it as a whole word,
|
|
158
|
+
* Rigour's outputs aside: one `git grep -o` pass per batch of names instead of one per name.
|
|
159
|
+
*/
|
|
160
|
+
function filesNaming(cwd, names, excluded) {
|
|
161
|
+
const found = new Map();
|
|
162
|
+
const pathspec = ['.', ...excluded.map(p => `:(exclude)${p}`)];
|
|
163
|
+
for (let i = 0; i < names.length; i += NAMES_PER_SEARCH) {
|
|
164
|
+
const patterns = names.slice(i, i + NAMES_PER_SEARCH).flatMap(name => ['-e', name]);
|
|
165
|
+
const result = spawnSync('git', ['grep', '--untracked', '-z', '-o', '-w', '-F', ...patterns, '--', ...pathspec], { cwd, encoding: 'utf8', timeout: GIT_TIMEOUT_MS, maxBuffer: 256 * 1024 * 1024 });
|
|
166
|
+
if (result.status === 1)
|
|
167
|
+
continue;
|
|
168
|
+
if (result.status !== 0)
|
|
169
|
+
return undefined;
|
|
170
|
+
for (const line of result.stdout.split('\n')) {
|
|
171
|
+
const [file, name] = line.split('\0');
|
|
172
|
+
if (file && name)
|
|
173
|
+
found.set(name, (found.get(name) ?? new Set()).add(file));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return found;
|
|
177
|
+
}
|
|
178
|
+
function unused(exp) {
|
|
179
|
+
return {
|
|
180
|
+
id: 'unused-export',
|
|
181
|
+
title: 'Unused export',
|
|
182
|
+
details: `\`${exp.name}\` is exported but no other file uses it. An export nothing imports is dead code that readers and agents treat as part of the module's contract.`,
|
|
183
|
+
severity: 'medium',
|
|
184
|
+
provenance: 'traditional',
|
|
185
|
+
files: [exp.file],
|
|
186
|
+
line: exp.line,
|
|
187
|
+
hint: `Drop the \`export\` (or the declaration, if nothing in the file uses it either). If a framework or tool loads it by name, add \`${exp.name}\` to gates.unused_exports.allow.`,
|
|
188
|
+
};
|
|
189
|
+
}
|
package/dist/settings.js
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
|
-
import os from 'os';
|
|
2
1
|
import path from 'path';
|
|
2
|
+
import { rigourUserDir } from './utils/user-state.js';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
4
|
import { Logger } from './utils/logger.js';
|
|
5
5
|
/**
|
|
6
6
|
* Get the settings file path: ~/.rigour/settings.json
|
|
7
7
|
*/
|
|
8
8
|
export function getSettingsPath() {
|
|
9
|
-
|
|
10
|
-
return path.join(homeDir, '.rigour', 'settings.json');
|
|
9
|
+
return path.join(rigourUserDir(), 'settings.json');
|
|
11
10
|
}
|
|
12
11
|
/**
|
|
13
12
|
* Load settings from ~/.rigour/settings.json
|
package/dist/storage/db.js
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
* All public APIs are async. Graceful degradation if sqlite3 not installed.
|
|
8
8
|
*/
|
|
9
9
|
import path from 'path';
|
|
10
|
-
import os from 'os';
|
|
11
10
|
import fs from 'fs-extra';
|
|
12
11
|
import { createRequire } from 'module';
|
|
13
12
|
import { rigourUserDir } from '../utils/user-state.js';
|
|
@@ -27,7 +26,7 @@ function loadSqlite3() {
|
|
|
27
26
|
const searchPaths = [
|
|
28
27
|
import.meta.url,
|
|
29
28
|
`file://${process.cwd()}/package.json`,
|
|
30
|
-
`file://${path.join(
|
|
29
|
+
`file://${path.join(rigourUserDir(), 'package.json')}`,
|
|
31
30
|
];
|
|
32
31
|
for (const base of searchPaths) {
|
|
33
32
|
try {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What may leave this machine for the configured team.
|
|
3
|
+
*
|
|
4
|
+
* One machine often works in repositories of more than one organization (a consultant, an
|
|
5
|
+
* employee with side projects), and the local outbox holds lessons from all of them. Only lessons
|
|
6
|
+
* from the team's own repositories are sent; personal lessons stay on the machine unless the team
|
|
7
|
+
* opts in. Checked when a queued item is about to be sent, so items queued by older versions are
|
|
8
|
+
* held to the same rule.
|
|
9
|
+
*/
|
|
10
|
+
export interface TeamScope {
|
|
11
|
+
organizationId: string;
|
|
12
|
+
/** Repository patterns such as `github.com/acme/*` or `gitlab.com/acme/api`; matched against the origin remote. */
|
|
13
|
+
repositories?: string[];
|
|
14
|
+
/** Also send lessons marked personal (default false). */
|
|
15
|
+
syncPersonal?: boolean;
|
|
16
|
+
}
|
|
17
|
+
/** Whether a repository, by its recorded origin remote, belongs to the team. */
|
|
18
|
+
export declare function repositoryAllowed(canonicalUri: string, scope: TeamScope): boolean;
|
|
19
|
+
/** Why a queued lesson must stay on this machine, or null to send it. */
|
|
20
|
+
export declare function withheldReason(visibility: string, canonicalUri: string | undefined, scope: TeamScope): string | null;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What may leave this machine for the configured team.
|
|
3
|
+
*
|
|
4
|
+
* One machine often works in repositories of more than one organization (a consultant, an
|
|
5
|
+
* employee with side projects), and the local outbox holds lessons from all of them. Only lessons
|
|
6
|
+
* from the team's own repositories are sent; personal lessons stay on the machine unless the team
|
|
7
|
+
* opts in. Checked when a queued item is about to be sent, so items queued by older versions are
|
|
8
|
+
* held to the same rule.
|
|
9
|
+
*/
|
|
10
|
+
/** The origin remote as Rigour records it, without its scheme: `github.com/acme/api`. */
|
|
11
|
+
function hostPath(canonicalUri) {
|
|
12
|
+
return canonicalUri.replace(/^[a-z][a-z0-9+.-]*:\/\//i, '').replace(/^[^@/]+@/, '').toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
/** Whether a repository, by its recorded origin remote, belongs to the team. */
|
|
15
|
+
export function repositoryAllowed(canonicalUri, scope) {
|
|
16
|
+
const repo = hostPath(canonicalUri);
|
|
17
|
+
// No list, nothing leaves: a team names its repositories before any lesson is sent.
|
|
18
|
+
return (scope.repositories ?? []).some(pattern => {
|
|
19
|
+
const want = hostPath(pattern.trim());
|
|
20
|
+
return want.endsWith('/*') ? repo.startsWith(want.slice(0, -1)) : repo === want;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/** Why a queued lesson must stay on this machine, or null to send it. */
|
|
24
|
+
export function withheldReason(visibility, canonicalUri, scope) {
|
|
25
|
+
if (visibility === 'personal' && !scope.syncPersonal)
|
|
26
|
+
return 'personal lesson';
|
|
27
|
+
if (!canonicalUri)
|
|
28
|
+
return 'repository unknown on this machine';
|
|
29
|
+
if (!repositoryAllowed(canonicalUri, scope))
|
|
30
|
+
return `repository ${hostPath(canonicalUri)} is not one of the team's repositories`;
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { repositoryAllowed, withheldReason } from './team-scope.js';
|
|
3
|
+
const scope = { organizationId: 'acme', repositories: ['github.com/acme/*', 'https://GitLab.com/Acme/api'] };
|
|
4
|
+
describe('team scope', () => {
|
|
5
|
+
it('sends lessons only from the repositories the team lists', () => {
|
|
6
|
+
expect(repositoryAllowed('https://github.com/acme/web', scope)).toBe(true);
|
|
7
|
+
expect(repositoryAllowed('https://gitlab.com/acme/api', scope)).toBe(true);
|
|
8
|
+
expect(repositoryAllowed('https://gitlab.com/acme/api-internal', scope)).toBe(false);
|
|
9
|
+
expect(repositoryAllowed('https://github.com/other-org/web', scope)).toBe(false);
|
|
10
|
+
expect(repositoryAllowed('https://github.com/acme-evil/web', scope)).toBe(false);
|
|
11
|
+
});
|
|
12
|
+
it('sends nothing when the team lists no repositories', () => {
|
|
13
|
+
expect(repositoryAllowed('https://github.com/acme/web', { organizationId: 'acme' })).toBe(false);
|
|
14
|
+
});
|
|
15
|
+
it('keeps personal lessons and unknown repositories on the machine, and says why', () => {
|
|
16
|
+
expect(withheldReason('personal', 'https://github.com/acme/web', scope)).toBe('personal lesson');
|
|
17
|
+
expect(withheldReason('personal', 'https://github.com/acme/web', { ...scope, syncPersonal: true })).toBeNull();
|
|
18
|
+
expect(withheldReason('team', undefined, scope)).toBe('repository unknown on this machine');
|
|
19
|
+
expect(withheldReason('team', 'https://github.com/other-org/web', scope)).toContain('github.com/other-org/web');
|
|
20
|
+
expect(withheldReason('team', 'https://github.com/acme/web', scope)).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -4,6 +4,10 @@ export interface TeamConfiguration {
|
|
|
4
4
|
actorId: string;
|
|
5
5
|
databaseUrl?: string;
|
|
6
6
|
semantic?: TeamSemanticConfiguration;
|
|
7
|
+
/** The team's repositories (`github.com/acme/*`); lessons from any other repository stay local. */
|
|
8
|
+
repositories?: string[];
|
|
9
|
+
/** Also send lessons marked personal (default false). */
|
|
10
|
+
syncPersonal?: boolean;
|
|
7
11
|
}
|
|
8
12
|
export interface TeamSemanticConfiguration {
|
|
9
13
|
provider: 'pgvector';
|
|
@@ -64,6 +68,7 @@ export declare function syncTeamOutbox(options?: {
|
|
|
64
68
|
dryRun?: boolean;
|
|
65
69
|
}): Promise<{
|
|
66
70
|
pending: number;
|
|
71
|
+
withheld: number;
|
|
67
72
|
synced: number;
|
|
68
73
|
pulled: number;
|
|
69
74
|
}>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from 'child_process';
|
|
1
2
|
import fs from 'fs-extra';
|
|
2
3
|
import path from 'path';
|
|
3
4
|
import { openDatabase } from './db.js';
|
|
@@ -5,6 +6,7 @@ import { decryptLocalPayload, encryptLocalPayload } from './local-encryption.js'
|
|
|
5
6
|
import { TEAM_SCHEMA } from './team-schema.js';
|
|
6
7
|
import { diagnoseMissingMembership, explainTeamConnectionError } from './team-diagnostics.js';
|
|
7
8
|
import { rigourUserDir } from '../utils/user-state.js';
|
|
9
|
+
import { withheldReason } from './team-scope.js';
|
|
8
10
|
import { TEAM_VECTOR_SCHEMA, backfillConfiguredTeamEmbeddings, getTeamSemanticHealth, searchConfiguredTeamKnowledge, upsertTeamLessonEmbedding, } from './team-vector-store.js';
|
|
9
11
|
const DEFAULT_SEMANTIC = {
|
|
10
12
|
provider: 'pgvector',
|
|
@@ -37,12 +39,27 @@ export async function loadTeamConfiguration() {
|
|
|
37
39
|
const organizationId = nonEmpty(process.env.RIGOUR_ORGANIZATION_ID) ?? nonEmpty(file.organizationId);
|
|
38
40
|
const teamId = nonEmpty(process.env.RIGOUR_TEAM_ID) ?? nonEmpty(file.teamId);
|
|
39
41
|
const actorId = nonEmpty(process.env.RIGOUR_ACTOR_ID) ?? nonEmpty(file.actorId);
|
|
40
|
-
const databaseUrl = nonEmpty(process.env.RIGOUR_TEAM_DATABASE_URL) ?? nonEmpty(file.databaseUrl);
|
|
42
|
+
const databaseUrl = nonEmpty(process.env.RIGOUR_TEAM_DATABASE_URL) ?? databaseUrlFromCommand() ?? nonEmpty(file.databaseUrl);
|
|
41
43
|
if (!organizationId || !teamId || !actorId || !databaseUrl)
|
|
42
44
|
return null;
|
|
43
45
|
const envSemantic = process.env.RIGOUR_TEAM_SEMANTIC === 'pgvector' ? DEFAULT_SEMANTIC : undefined;
|
|
44
46
|
const semantic = envSemantic ?? (file.semantic?.provider === 'pgvector' ? DEFAULT_SEMANTIC : undefined);
|
|
45
|
-
|
|
47
|
+
const envRepositories = nonEmpty(process.env.RIGOUR_TEAM_REPOSITORIES)?.split(',').map(r => r.trim()).filter(Boolean);
|
|
48
|
+
const repositories = envRepositories ?? (Array.isArray(file.repositories) ? file.repositories.filter(r => typeof r === 'string') : undefined);
|
|
49
|
+
const syncPersonal = process.env.RIGOUR_TEAM_SYNC_PERSONAL !== undefined ? process.env.RIGOUR_TEAM_SYNC_PERSONAL === '1' : file.syncPersonal === true;
|
|
50
|
+
return { organizationId, teamId, actorId, databaseUrl, semantic, repositories, syncPersonal };
|
|
51
|
+
}
|
|
52
|
+
let commandUrl;
|
|
53
|
+
/** A profile's `databaseUrlCommand` (RIGOUR_TEAM_DATABASE_URL_COMMAND): run once per process, its output never stored. */
|
|
54
|
+
function databaseUrlFromCommand() {
|
|
55
|
+
const command = nonEmpty(process.env.RIGOUR_TEAM_DATABASE_URL_COMMAND);
|
|
56
|
+
if (!command)
|
|
57
|
+
return undefined;
|
|
58
|
+
if (commandUrl === undefined) {
|
|
59
|
+
const result = spawnSync(command, { shell: true, encoding: 'utf8', timeout: 15_000 });
|
|
60
|
+
commandUrl = result.status === 0 ? nonEmpty(result.stdout) ?? null : null;
|
|
61
|
+
}
|
|
62
|
+
return commandUrl ?? undefined;
|
|
46
63
|
}
|
|
47
64
|
export async function saveTeamConfiguration(config) {
|
|
48
65
|
if (!config.databaseUrl)
|
|
@@ -176,6 +193,21 @@ export async function doctorTeamConnection(config) {
|
|
|
176
193
|
await pool.end().catch(() => undefined);
|
|
177
194
|
}
|
|
178
195
|
}
|
|
196
|
+
/** Queued items split into those this team may receive and those that stay on this machine, with why. */
|
|
197
|
+
async function splitOutbox(db, queued, config) {
|
|
198
|
+
const sendable = [];
|
|
199
|
+
const held = [];
|
|
200
|
+
for (const item of queued) {
|
|
201
|
+
const lesson = await decryptLocalPayload(item.payload_json);
|
|
202
|
+
const repo = await db.get('SELECT canonical_uri FROM repositories WHERE id = ?', lesson.repositoryId);
|
|
203
|
+
const reason = withheldReason(lesson.visibility, repo?.canonical_uri, config);
|
|
204
|
+
if (reason)
|
|
205
|
+
held.push({ id: item.id, reason });
|
|
206
|
+
else
|
|
207
|
+
sendable.push({ item, lesson });
|
|
208
|
+
}
|
|
209
|
+
return { sendable, held };
|
|
210
|
+
}
|
|
179
211
|
export async function syncTeamOutbox(options = {}) {
|
|
180
212
|
const config = await loadTeamConfiguration();
|
|
181
213
|
if (!config?.databaseUrl)
|
|
@@ -184,9 +216,13 @@ export async function syncTeamOutbox(options = {}) {
|
|
|
184
216
|
if (!db)
|
|
185
217
|
throw new Error('SQLite offline cache is unavailable.');
|
|
186
218
|
try {
|
|
187
|
-
const
|
|
219
|
+
const queued = await db.all(`SELECT * FROM sync_outbox WHERE synced_at IS NULL ORDER BY created_at ASC LIMIT 200`);
|
|
220
|
+
const { sendable: pending, held } = await splitOutbox(db, queued, config);
|
|
188
221
|
if (options.dryRun)
|
|
189
|
-
return { pending: pending.length, synced: 0, pulled: 0 };
|
|
222
|
+
return { pending: pending.length, withheld: held.length, synced: 0, pulled: 0 };
|
|
223
|
+
for (const item of held) {
|
|
224
|
+
await db.run('UPDATE sync_outbox SET synced_at = ?, last_error = ? WHERE id = ?', Date.now(), `not sent: ${item.reason}`, item.id);
|
|
225
|
+
}
|
|
190
226
|
const { Pool } = await loadPg();
|
|
191
227
|
const pool = new Pool({ connectionString: config.databaseUrl });
|
|
192
228
|
let synced = 0;
|
|
@@ -197,8 +233,7 @@ export async function syncTeamOutbox(options = {}) {
|
|
|
197
233
|
AND actor_id = $2 AND organization_id = $3`, [config.teamId, config.actorId, config.organizationId]);
|
|
198
234
|
if (membership.rowCount !== 1)
|
|
199
235
|
throw new Error(await diagnoseMissingMembership(pool));
|
|
200
|
-
for (const item of pending) {
|
|
201
|
-
const lesson = await decryptLocalPayload(item.payload_json);
|
|
236
|
+
for (const { item, lesson } of pending) {
|
|
202
237
|
try {
|
|
203
238
|
const result = await pool.query(`INSERT INTO rigour.lessons (
|
|
204
239
|
id, organization_id, team_id, repository_id, actor_id, visibility, state,
|
|
@@ -254,7 +289,7 @@ export async function syncTeamOutbox(options = {}) {
|
|
|
254
289
|
finally {
|
|
255
290
|
await pool.end();
|
|
256
291
|
}
|
|
257
|
-
return { pending: pending.length, synced, pulled };
|
|
292
|
+
return { pending: pending.length, withheld: held.length, synced, pulled };
|
|
258
293
|
}
|
|
259
294
|
finally {
|
|
260
295
|
await db.close();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
/** The outbox and repositories a machine holds, and what reached the team database. */
|
|
3
|
+
const state = vi.hoisted(() => ({ outbox: [], repos: new Map(), sent: [] }));
|
|
4
|
+
vi.mock('./db.js', () => ({
|
|
5
|
+
openDatabase: async () => ({
|
|
6
|
+
all: async () => state.outbox.filter(o => o.synced_at == null),
|
|
7
|
+
get: async (_sql, id) => (state.repos.has(id) ? { canonical_uri: state.repos.get(id) } : undefined),
|
|
8
|
+
run: async (sql, ...p) => {
|
|
9
|
+
if (sql.startsWith('UPDATE sync_outbox SET synced_at')) {
|
|
10
|
+
const id = p.at(-1);
|
|
11
|
+
Object.assign(state.outbox.find(o => o.id === id) ?? {}, { synced_at: p[0], last_error: p.length === 3 ? p[1] : null });
|
|
12
|
+
}
|
|
13
|
+
return { changes: 1 };
|
|
14
|
+
},
|
|
15
|
+
close: async () => undefined,
|
|
16
|
+
}),
|
|
17
|
+
}));
|
|
18
|
+
vi.mock('./local-encryption.js', () => ({ encryptLocalPayload: async (v) => JSON.stringify(v), decryptLocalPayload: async (v) => JSON.parse(v) }));
|
|
19
|
+
vi.mock('pg', () => ({
|
|
20
|
+
Pool: class {
|
|
21
|
+
async query(sql, params) {
|
|
22
|
+
if (sql.includes('INSERT INTO rigour.lessons')) {
|
|
23
|
+
state.sent.push(params[0]);
|
|
24
|
+
return { rowCount: 1, rows: [] };
|
|
25
|
+
}
|
|
26
|
+
return { rowCount: 1, rows: [] };
|
|
27
|
+
}
|
|
28
|
+
async end() { }
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
const { syncTeamOutbox } = await import('./team-store.js');
|
|
32
|
+
const queue = (id, repositoryId, visibility) => state.outbox.push({ id: `outbox-${id}`, payload_json: JSON.stringify({ id, repositoryId, visibility, evidence: {} }), synced_at: null });
|
|
33
|
+
const TEAM_ENV = {
|
|
34
|
+
RIGOUR_ORGANIZATION_ID: 'acme', RIGOUR_TEAM_ID: 'web', RIGOUR_ACTOR_ID: 'dev-1',
|
|
35
|
+
RIGOUR_TEAM_DATABASE_URL: 'postgresql://localhost/rigour', RIGOUR_TEAM_REPOSITORIES: 'github.com/acme/*',
|
|
36
|
+
};
|
|
37
|
+
const saved = { ...process.env };
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
Object.assign(process.env, TEAM_ENV);
|
|
40
|
+
delete process.env.RIGOUR_TEAM_SYNC_PERSONAL;
|
|
41
|
+
state.outbox = [];
|
|
42
|
+
state.sent = [];
|
|
43
|
+
state.repos = new Map([['r-team', 'https://github.com/acme/web'], ['r-other', 'https://github.com/other-org/app']]);
|
|
44
|
+
queue('l-team', 'r-team', 'team');
|
|
45
|
+
queue('l-other', 'r-other', 'team');
|
|
46
|
+
queue('l-personal', 'r-team', 'personal');
|
|
47
|
+
queue('l-unknown', 'r-gone', 'team');
|
|
48
|
+
});
|
|
49
|
+
afterEach(() => { process.env = { ...saved }; });
|
|
50
|
+
describe('team sync scope', () => {
|
|
51
|
+
it("sends only the team's own shared lessons and says what it kept back", async () => {
|
|
52
|
+
expect(await syncTeamOutbox({ dryRun: true })).toMatchObject({ pending: 1, withheld: 3 });
|
|
53
|
+
expect(state.sent).toEqual([]);
|
|
54
|
+
const result = await syncTeamOutbox();
|
|
55
|
+
expect(result).toMatchObject({ synced: 1, withheld: 3 });
|
|
56
|
+
expect(state.sent).toEqual(['l-team']);
|
|
57
|
+
const reasons = Object.fromEntries(state.outbox.map(o => [o.id, o.last_error]));
|
|
58
|
+
expect(reasons['outbox-l-other']).toContain('github.com/other-org/app');
|
|
59
|
+
expect(reasons['outbox-l-personal']).toContain('personal lesson');
|
|
60
|
+
expect(reasons['outbox-l-unknown']).toContain('repository unknown');
|
|
61
|
+
expect(await syncTeamOutbox({ dryRun: true })).toMatchObject({ pending: 0, withheld: 0 }); // never retried
|
|
62
|
+
});
|
|
63
|
+
it('sends nothing when the team lists no repositories', async () => {
|
|
64
|
+
delete process.env.RIGOUR_TEAM_REPOSITORIES;
|
|
65
|
+
expect(await syncTeamOutbox({ dryRun: true })).toMatchObject({ pending: 0, withheld: 4 });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import crypto from 'crypto';
|
|
18
18
|
import fs from 'fs';
|
|
19
|
-
import os from 'os';
|
|
20
19
|
import path from 'path';
|
|
20
|
+
import { rigourHome } from '../utils/user-state.js';
|
|
21
21
|
import { MIXPANEL_TOKEN } from './token.js';
|
|
22
22
|
const ENDPOINT = 'https://api.mixpanel.com/track?ip=0';
|
|
23
23
|
const TIMEOUT_MS = 2000;
|
|
@@ -28,7 +28,7 @@ export function telemetryToken(env = process.env) {
|
|
|
28
28
|
function file(home, name) {
|
|
29
29
|
return path.join(home, '.rigour', name);
|
|
30
30
|
}
|
|
31
|
-
export function readTelemetryState(home =
|
|
31
|
+
export function readTelemetryState(home = rigourHome()) {
|
|
32
32
|
try {
|
|
33
33
|
const parsed = JSON.parse(fs.readFileSync(file(home, 'telemetry.json'), 'utf8'));
|
|
34
34
|
if (typeof parsed?.installId === 'string')
|
|
@@ -39,7 +39,7 @@ export function readTelemetryState(home = os.homedir()) {
|
|
|
39
39
|
}
|
|
40
40
|
return { installId: crypto.randomUUID() };
|
|
41
41
|
}
|
|
42
|
-
export function setTelemetryEnabled(enabled, home =
|
|
42
|
+
export function setTelemetryEnabled(enabled, home = rigourHome()) {
|
|
43
43
|
const state = { ...readTelemetryState(home), enabled };
|
|
44
44
|
writeJson(file(home, 'telemetry.json'), state);
|
|
45
45
|
return state;
|
|
@@ -103,7 +103,7 @@ export function countUsage(name, by = 1, deps = {}) {
|
|
|
103
103
|
if (!isTelemetryEnabled(deps))
|
|
104
104
|
return;
|
|
105
105
|
try {
|
|
106
|
-
const home = deps.home ??
|
|
106
|
+
const home = deps.home ?? rigourHome();
|
|
107
107
|
const counters = readCounters(home, deps.now ?? Date.now());
|
|
108
108
|
counters.counts[name] = (counters.counts[name] ?? 0) + by;
|
|
109
109
|
writeJson(file(home, 'telemetry-counters.json'), counters);
|
|
@@ -116,7 +116,7 @@ export function countUsage(name, by = 1, deps = {}) {
|
|
|
116
116
|
export async function flushDailyUsage(deps = {}) {
|
|
117
117
|
if (!isTelemetryEnabled(deps))
|
|
118
118
|
return false;
|
|
119
|
-
const home = deps.home ??
|
|
119
|
+
const home = deps.home ?? rigourHome();
|
|
120
120
|
const now = deps.now ?? Date.now();
|
|
121
121
|
const counters = readCounters(home, now);
|
|
122
122
|
if (now - counters.since < DAY_MS || Object.keys(counters.counts).length === 0)
|
|
@@ -241,6 +241,23 @@ export const UNIVERSAL_CONFIG = {
|
|
|
241
241
|
deprecated_dependencies: {
|
|
242
242
|
enabled: false,
|
|
243
243
|
},
|
|
244
|
+
unused_exports: {
|
|
245
|
+
enabled: true,
|
|
246
|
+
allow: [],
|
|
247
|
+
},
|
|
248
|
+
orphan_files: {
|
|
249
|
+
enabled: true,
|
|
250
|
+
allow: [],
|
|
251
|
+
},
|
|
252
|
+
query_patterns: {
|
|
253
|
+
enabled: true,
|
|
254
|
+
},
|
|
255
|
+
optional_params: {
|
|
256
|
+
enabled: true,
|
|
257
|
+
},
|
|
258
|
+
duplicate_functions: {
|
|
259
|
+
enabled: true,
|
|
260
|
+
},
|
|
244
261
|
migration_order: {
|
|
245
262
|
enabled: false,
|
|
246
263
|
dirs: ['**/supabase/migrations'],
|
|
@@ -294,6 +311,12 @@ export const UNIVERSAL_CONFIG = {
|
|
|
294
311
|
},
|
|
295
312
|
review: {
|
|
296
313
|
include_heuristics: false,
|
|
314
|
+
show_preexisting: false,
|
|
315
|
+
reviewer: {
|
|
316
|
+
enabled: false,
|
|
317
|
+
command: 'claude',
|
|
318
|
+
timeout_ms: 900000,
|
|
319
|
+
},
|
|
297
320
|
},
|
|
298
321
|
planned: [],
|
|
299
322
|
ignore: [],
|