@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.
Files changed (83) hide show
  1. package/dist/deep/diff-tests/run.js +2 -12
  2. package/dist/firewall/firewall.test.js +4 -0
  3. package/dist/firewall/trusted-control.js +2 -2
  4. package/dist/firewall/typed-command.js +2 -1
  5. package/dist/gates/logic-drift-git-base.d.ts +9 -0
  6. package/dist/gates/logic-drift-git-base.js +16 -11
  7. package/dist/gates/runner.d.ts +3 -0
  8. package/dist/gates/runner.js +4 -1
  9. package/dist/hooks/index.d.ts +2 -2
  10. package/dist/hooks/index.js +2 -2
  11. package/dist/hooks/session-state.d.ts +9 -0
  12. package/dist/hooks/session-state.js +33 -0
  13. package/dist/hooks/stop-review.d.ts +18 -11
  14. package/dist/hooks/stop-review.js +24 -8
  15. package/dist/hooks/templates.d.ts +8 -1
  16. package/dist/hooks/templates.js +19 -2
  17. package/dist/hooks/templates.test.js +12 -1
  18. package/dist/index.d.ts +4 -0
  19. package/dist/index.js +4 -0
  20. package/dist/review/baseline.d.ts +16 -0
  21. package/dist/review/baseline.js +96 -0
  22. package/dist/review/baseline.test.d.ts +1 -0
  23. package/dist/review/baseline.test.js +22 -0
  24. package/dist/review/branch-checks.d.ts +6 -0
  25. package/dist/review/branch-checks.js +83 -0
  26. package/dist/review/branch-checks.test.d.ts +1 -0
  27. package/dist/review/branch-checks.test.js +48 -0
  28. package/dist/review/code-patterns.test.d.ts +1 -0
  29. package/dist/review/code-patterns.test.js +84 -0
  30. package/dist/review/dead-code.test.d.ts +1 -0
  31. package/dist/review/dead-code.test.js +126 -0
  32. package/dist/review/duplicate-functions.d.ts +2 -0
  33. package/dist/review/duplicate-functions.js +98 -0
  34. package/dist/review/effectiveness.d.ts +3 -0
  35. package/dist/review/optional-params.d.ts +2 -0
  36. package/dist/review/optional-params.js +123 -0
  37. package/dist/review/orphan-files.d.ts +2 -0
  38. package/dist/review/orphan-files.js +125 -0
  39. package/dist/review/package-layout.d.ts +9 -0
  40. package/dist/review/package-layout.js +108 -0
  41. package/dist/review/query-patterns.d.ts +2 -0
  42. package/dist/review/query-patterns.js +153 -0
  43. package/dist/review/quiet.js +1 -1
  44. package/dist/review/review.d.ts +2 -0
  45. package/dist/review/review.js +29 -1
  46. package/dist/review/review.test.js +28 -2
  47. package/dist/review/reviewer.d.ts +55 -0
  48. package/dist/review/reviewer.js +224 -0
  49. package/dist/review/reviewer.test.d.ts +1 -0
  50. package/dist/review/reviewer.test.js +99 -0
  51. package/dist/review/test-files.d.ts +2 -0
  52. package/dist/review/test-files.js +4 -0
  53. package/dist/review/toolchain.d.ts +11 -0
  54. package/dist/review/toolchain.js +126 -0
  55. package/dist/review/toolchain.test.d.ts +1 -0
  56. package/dist/review/toolchain.test.js +67 -0
  57. package/dist/review/unused-exports.d.ts +4 -0
  58. package/dist/review/unused-exports.js +189 -0
  59. package/dist/settings.js +2 -3
  60. package/dist/storage/db.js +1 -2
  61. package/dist/storage/team-scope.d.ts +20 -0
  62. package/dist/storage/team-scope.js +32 -0
  63. package/dist/storage/team-scope.test.d.ts +1 -0
  64. package/dist/storage/team-scope.test.js +22 -0
  65. package/dist/storage/team-store.d.ts +5 -0
  66. package/dist/storage/team-store.js +42 -7
  67. package/dist/storage/team-sync-scope.test.d.ts +1 -0
  68. package/dist/storage/team-sync-scope.test.js +67 -0
  69. package/dist/telemetry/telemetry.js +5 -5
  70. package/dist/templates/universal-config.js +23 -0
  71. package/dist/types/index.d.ts +253 -6
  72. package/dist/types/index.js +27 -0
  73. package/dist/utils/installed-bin.d.ts +2 -0
  74. package/dist/utils/installed-bin.js +15 -0
  75. package/dist/utils/profile.d.ts +30 -0
  76. package/dist/utils/profile.js +116 -0
  77. package/dist/utils/profile.test.d.ts +1 -0
  78. package/dist/utils/profile.test.js +58 -0
  79. package/dist/utils/scanner.js +3 -1
  80. package/dist/utils/scanner.test.js +6 -2
  81. package/dist/utils/user-state.d.ts +3 -1
  82. package/dist/utils/user-state.js +6 -2
  83. 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
- const homeDir = os.homedir();
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
@@ -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(os.homedir(), '.rigour', 'package.json')}`,
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
- return { organizationId, teamId, actorId, databaseUrl, semantic };
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 pending = await db.all(`SELECT * FROM sync_outbox WHERE synced_at IS NULL ORDER BY created_at ASC LIMIT 200`);
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 = os.homedir()) {
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 = os.homedir()) {
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 ?? os.homedir();
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 ?? os.homedir();
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: [],