@rigour-labs/core 6.5.0 → 6.5.2
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/fact-extractor.js +2 -1
- package/dist/deep/fact-extractor.test.js +2 -1
- package/dist/gates/ast.js +4 -2
- package/dist/gates/coverage.js +4 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/pattern-index/indexer.js +4 -2
- package/dist/review/quiet.d.ts +2 -0
- package/dist/review/quiet.js +4 -2
- package/dist/review/quiet.test.js +1 -1
- package/dist/review/review.d.ts +4 -1
- package/dist/review/review.js +12 -4
- package/dist/review/review.test.js +25 -1
- package/dist/telemetry/telemetry.d.ts +28 -0
- package/dist/telemetry/telemetry.js +152 -0
- package/dist/telemetry/telemetry.test.d.ts +1 -0
- package/dist/telemetry/telemetry.test.js +59 -0
- package/dist/telemetry/token.d.ts +1 -0
- package/dist/telemetry/token.js +1 -0
- package/dist/utils/glob-paths.d.ts +4 -0
- package/dist/utils/glob-paths.js +26 -0
- package/dist/utils/glob-paths.test.d.ts +1 -0
- package/dist/utils/glob-paths.test.js +40 -0
- package/dist/utils/scanner.js +4 -2
- package/dist/utils/scanner.test.js +2 -1
- package/package.json +6 -6
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from 'fs-extra';
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import { globby } from 'globby';
|
|
9
|
+
import { directoriesAsGlobs, withDirectoryForms } from '../utils/glob-paths.js';
|
|
9
10
|
const ANALYZABLE_EXTENSION = /\.(ts|js|tsx|jsx|py|go|rs|cs|java|rb|kt)$/i;
|
|
10
11
|
/**
|
|
11
12
|
* Lightweight regex-based fact extraction.
|
|
@@ -20,7 +21,7 @@ export async function extractFacts(cwd, ignore, scope) {
|
|
|
20
21
|
'**/.git/**', '**/vendor/**', '**/__pycache__/**',
|
|
21
22
|
'**/*.min.js', '**/*.bundle.js',
|
|
22
23
|
];
|
|
23
|
-
const matched = await globby(patterns, { cwd, ignore: ignorePatterns, followSymbolicLinks: false });
|
|
24
|
+
const matched = await globby(directoriesAsGlobs(cwd, patterns), { cwd, ignore: withDirectoryForms(ignorePatterns), expandDirectories: false, followSymbolicLinks: false });
|
|
24
25
|
const files = matched.filter(f => ANALYZABLE_EXTENSION.test(f));
|
|
25
26
|
const allFacts = [];
|
|
26
27
|
for (const file of files) {
|
|
@@ -3,7 +3,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
3
3
|
// But we need to test extraction logic directly, so we also test via the public API
|
|
4
4
|
const mockGlobby = vi.hoisted(() => vi.fn());
|
|
5
5
|
const mockReadFile = vi.hoisted(() => vi.fn());
|
|
6
|
-
vi.mock('globby', () => ({
|
|
6
|
+
vi.mock('globby', async (importOriginal) => ({
|
|
7
|
+
isDynamicPattern: (await importOriginal()).isDynamicPattern,
|
|
7
8
|
globby: mockGlobby,
|
|
8
9
|
}));
|
|
9
10
|
vi.mock('fs-extra', () => ({
|
package/dist/gates/ast.js
CHANGED
|
@@ -5,6 +5,7 @@ import { Gate } from './base.js';
|
|
|
5
5
|
import { TypeScriptHandler } from './ast-handlers/typescript.js';
|
|
6
6
|
import { PythonHandler } from './ast-handlers/python.js';
|
|
7
7
|
import { UniversalASTHandler } from './ast-handlers/universal.js';
|
|
8
|
+
import { directoriesAsGlobs, withDirectoryForms } from '../utils/glob-paths.js';
|
|
8
9
|
export class ASTGate extends Gate {
|
|
9
10
|
config;
|
|
10
11
|
handlers = [];
|
|
@@ -35,9 +36,10 @@ export class ASTGate extends Gate {
|
|
|
35
36
|
const ignore = [...new Set([...(context.ignore || []), ...defaultIgnore])].map(p => p.replace(/\\/g, '/'));
|
|
36
37
|
const normalizedCwd = context.cwd.replace(/\\/g, '/');
|
|
37
38
|
// Find all supported files
|
|
38
|
-
const files = await globby(patterns, {
|
|
39
|
+
const files = await globby(directoriesAsGlobs(normalizedCwd, patterns), {
|
|
39
40
|
cwd: normalizedCwd,
|
|
40
|
-
ignore: ignore,
|
|
41
|
+
ignore: withDirectoryForms(ignore),
|
|
42
|
+
expandDirectories: false,
|
|
41
43
|
});
|
|
42
44
|
// Process files concurrently in batches for performance
|
|
43
45
|
const CONCURRENCY = 16;
|
package/dist/gates/coverage.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import readline from 'node:readline';
|
|
4
4
|
import { Gate } from './base.js';
|
|
5
5
|
import { globby } from 'globby';
|
|
6
|
+
import { withDirectoryForms } from '../utils/glob-paths.js';
|
|
6
7
|
export class CoverageGate extends Gate {
|
|
7
8
|
config;
|
|
8
9
|
constructor(config) {
|
|
@@ -62,7 +63,8 @@ export class CoverageGate extends Gate {
|
|
|
62
63
|
followSymbolicLinks: false,
|
|
63
64
|
onlyFiles: true,
|
|
64
65
|
deep: 8,
|
|
65
|
-
|
|
66
|
+
expandDirectories: false,
|
|
67
|
+
ignore: withDirectoryForms([...new Set([
|
|
66
68
|
...(context.ignore || []),
|
|
67
69
|
'**/node_modules/**',
|
|
68
70
|
'**/.git/**',
|
|
@@ -75,7 +77,7 @@ export class CoverageGate extends Gate {
|
|
|
75
77
|
'**/vendor/**',
|
|
76
78
|
'**/.venv/**',
|
|
77
79
|
'**/venv/**',
|
|
78
|
-
])],
|
|
80
|
+
])]),
|
|
79
81
|
});
|
|
80
82
|
if (reports.length === 0)
|
|
81
83
|
return null;
|
package/dist/index.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export { reviewChange, toReviewFinding, type ReviewInput, type ReviewResult, typ
|
|
|
15
15
|
export { splitByChangedLines, type ChangedLineSplit } from './review/changed-lines.js';
|
|
16
16
|
export { dismissFinding, dismissedKeys, findingKey, isProven, quietSplit, DISMISSED_FILE } from './review/quiet.js';
|
|
17
17
|
export { isGeneratedFile } from './review/generated-files.js';
|
|
18
|
+
export { countUsage, durationBucket, flushDailyUsage, isTelemetryEnabled, readTelemetryState, setTelemetryEnabled, shouldAskTelemetry, telemetryToken, trackUsage } from './telemetry/telemetry.js';
|
|
18
19
|
export { buildReviewTask, type ReviewTask, type ReviewTaskItem } from './review/review-task.js';
|
|
19
20
|
export { acknowledgeReview, type ReviewAck, type AckResult } from './review/review-ack.js';
|
|
20
21
|
export { exportReviewed, readLedger, reviewedKeys, isReviewed, recordReview, REVIEWED_FILE, type LedgerEntry, type ReviewedKey, type ReviewVerdict } from './review/ledger.js';
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { reviewChange, toReviewFinding } from './review/review.js';
|
|
|
15
15
|
export { splitByChangedLines } from './review/changed-lines.js';
|
|
16
16
|
export { dismissFinding, dismissedKeys, findingKey, isProven, quietSplit, DISMISSED_FILE } from './review/quiet.js';
|
|
17
17
|
export { isGeneratedFile } from './review/generated-files.js';
|
|
18
|
+
export { countUsage, durationBucket, flushDailyUsage, isTelemetryEnabled, readTelemetryState, setTelemetryEnabled, shouldAskTelemetry, telemetryToken, trackUsage } from './telemetry/telemetry.js';
|
|
18
19
|
export { buildReviewTask } from './review/review-task.js';
|
|
19
20
|
export { acknowledgeReview } from './review/review-ack.js';
|
|
20
21
|
export { exportReviewed, readLedger, reviewedKeys, isReviewed, recordReview, REVIEWED_FILE } from './review/ledger.js';
|
|
@@ -17,6 +17,7 @@ import { generateEmbedding } from './embeddings.js';
|
|
|
17
17
|
import { hashContent } from './indexer-helpers.js';
|
|
18
18
|
import { extractGoPatterns, extractRustPatterns, extractJVMStylePatterns, extractPythonPatterns, extractGenericCPatterns, } from './indexer-lang.js';
|
|
19
19
|
import { nodeToPattern } from './indexer-ts.js';
|
|
20
|
+
import { directoriesAsGlobs, withDirectoryForms } from '../utils/glob-paths.js';
|
|
20
21
|
// ---------------------------------------------------------------------------
|
|
21
22
|
// Configuration
|
|
22
23
|
// ---------------------------------------------------------------------------
|
|
@@ -239,10 +240,11 @@ export class PatternIndexer {
|
|
|
239
240
|
if (!this.config.indexTests) {
|
|
240
241
|
exclude.push('**/*.test.*', '**/*.spec.*', '**/__tests__/**');
|
|
241
242
|
}
|
|
242
|
-
return globby(patterns, {
|
|
243
|
+
return globby(directoriesAsGlobs(this.rootDir, patterns), {
|
|
243
244
|
cwd: this.rootDir,
|
|
244
245
|
absolute: true,
|
|
245
|
-
ignore: exclude,
|
|
246
|
+
ignore: withDirectoryForms(exclude),
|
|
247
|
+
expandDirectories: false,
|
|
246
248
|
gitignore: true,
|
|
247
249
|
});
|
|
248
250
|
}
|
package/dist/review/quiet.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface QuietSplit {
|
|
|
7
7
|
speaking: Failure[];
|
|
8
8
|
advisory: Failure[];
|
|
9
9
|
dismissed: number;
|
|
10
|
+
/** Which gates the dismissed findings came from: where Rigour is wrong for this team. */
|
|
11
|
+
dismissedByGate: Record<string, number>;
|
|
10
12
|
}
|
|
11
13
|
export declare function quietSplit(cwd: string, findings: Failure[], includeHeuristics?: boolean): QuietSplit;
|
|
12
14
|
export declare function dismissedKeys(cwd: string): Set<string>;
|
package/dist/review/quiet.js
CHANGED
|
@@ -27,10 +27,12 @@ export function findingKey(failure) {
|
|
|
27
27
|
}
|
|
28
28
|
export function quietSplit(cwd, findings, includeHeuristics = false) {
|
|
29
29
|
const dismissed = dismissedKeys(cwd);
|
|
30
|
-
const split = { speaking: [], advisory: [], dismissed: 0 };
|
|
30
|
+
const split = { speaking: [], advisory: [], dismissed: 0, dismissedByGate: {} };
|
|
31
31
|
for (const finding of findings) {
|
|
32
|
-
if (dismissed.has(findingKey(finding)))
|
|
32
|
+
if (dismissed.has(findingKey(finding))) {
|
|
33
33
|
split.dismissed++;
|
|
34
|
+
split.dismissedByGate[finding.id] = (split.dismissedByGate[finding.id] ?? 0) + 1;
|
|
35
|
+
}
|
|
34
36
|
else if (includeHeuristics || isProven(finding))
|
|
35
37
|
split.speaking.push(finding);
|
|
36
38
|
else
|
|
@@ -20,7 +20,7 @@ describe('quiet by default', () => {
|
|
|
20
20
|
expect(dismissFinding(dir, findingKey(leak), 'test fixture token, never deployed')).toBe(true);
|
|
21
21
|
expect(dismissFinding(dir, findingKey(leak), 'again')).toBe(true); // idempotent
|
|
22
22
|
const moved = { ...leak, line: 40 };
|
|
23
|
-
expect(quietSplit(dir, [moved, finding('semantic-bugs')])).toMatchObject({ dismissed: 1, speaking: [expect.objectContaining({ id: 'semantic-bugs' })] });
|
|
23
|
+
expect(quietSplit(dir, [moved, finding('semantic-bugs')])).toMatchObject({ dismissed: 1, dismissedByGate: { 'security-patterns': 1 }, speaking: [expect.objectContaining({ id: 'semantic-bugs' })] });
|
|
24
24
|
expect(JSON.parse(fs.readFileSync(path.join(dir, '.rigour/dismissed.json'), 'utf8')).entries).toHaveLength(1);
|
|
25
25
|
expect(dismissFinding(dir, 'not-a-key', 'x')).toBe(false);
|
|
26
26
|
});
|
package/dist/review/review.d.ts
CHANGED
|
@@ -21,13 +21,16 @@ export interface ReviewResult {
|
|
|
21
21
|
contextFindings: Failure[];
|
|
22
22
|
/** Heuristic findings on changed lines: returned on request, never deciding the verdict (quiet.ts). */
|
|
23
23
|
advisory: Failure[];
|
|
24
|
-
/** Findings a person dismissed as not a bug. */
|
|
24
|
+
/** Findings a person dismissed as not a bug, and the gates they came from. */
|
|
25
25
|
dismissed: number;
|
|
26
|
+
dismissedByGate: Record<string, number>;
|
|
26
27
|
unlocated: number;
|
|
27
28
|
excludedOutsideChangedLines: number;
|
|
28
29
|
changedLines: Record<string, Set<number>>;
|
|
29
30
|
report: Report | null;
|
|
30
31
|
deepError?: string;
|
|
32
|
+
/** Gates that crashed instead of running; a proven one makes the result ERROR. */
|
|
33
|
+
gateErrors: string[];
|
|
31
34
|
}
|
|
32
35
|
export interface ReviewFinding {
|
|
33
36
|
id: string;
|
package/dist/review/review.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Runs the configured gates on the files a change touches and keeps what
|
|
6
6
|
* belongs to the change (see changed-lines.ts). A deep run that was asked for
|
|
7
|
-
* but could not analyze anything makes the result ERROR, never a clean PASS
|
|
7
|
+
* but could not analyze anything makes the result ERROR, never a clean PASS,
|
|
8
|
+
* and so does a proven gate that crashed (a heuristic one is only listed).
|
|
8
9
|
*/
|
|
9
10
|
import { GateRunner } from '../gates/runner.js';
|
|
10
11
|
import { changedLinesByFile, parseDiff, removedByFile } from '../utils/diff.js';
|
|
@@ -13,7 +14,7 @@ import { deepAnalysisError } from '../utils/deep-status.js';
|
|
|
13
14
|
import { splitByChangedLines } from './changed-lines.js';
|
|
14
15
|
import { changedFunctionSpans } from './changed-function-spans.js';
|
|
15
16
|
import { withoutGenerated } from './generated-files.js';
|
|
16
|
-
import { findingKey, quietSplit } from './quiet.js';
|
|
17
|
+
import { findingKey, isProven, quietSplit } from './quiet.js';
|
|
17
18
|
import { diffFromGit } from './git-diff.js';
|
|
18
19
|
import { diffTestFailures } from './diff-test-findings.js';
|
|
19
20
|
export async function reviewChange(input) {
|
|
@@ -21,7 +22,7 @@ export async function reviewChange(input) {
|
|
|
21
22
|
const changedLines = withoutGenerated(input.cwd, parseDiff(diff));
|
|
22
23
|
const targets = input.files?.length ? input.files : Object.keys(changedLines);
|
|
23
24
|
if (targets.length === 0) {
|
|
24
|
-
return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], dismissed: 0, unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null };
|
|
25
|
+
return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null, gateErrors: [] };
|
|
25
26
|
}
|
|
26
27
|
const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff), diff } : undefined;
|
|
27
28
|
const report = await new GateRunner(input.config).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
|
|
@@ -30,20 +31,27 @@ export async function reviewChange(input) {
|
|
|
30
31
|
const split = splitByChangedLines(report.failures, changedLines, deep ? changedFunctionSpans(input.cwd, changedLines) : {});
|
|
31
32
|
const deepError = deepAnalysisError(report);
|
|
32
33
|
const quiet = quietSplit(input.cwd, split.findings, input.config.review?.include_heuristics);
|
|
34
|
+
const gateErrors = crashedGates(report);
|
|
35
|
+
const provenCrashed = gateErrors.some(id => isProven({ id }));
|
|
33
36
|
return {
|
|
34
|
-
status: deepError ? 'ERROR' : quiet.speaking.length > 0 ? 'FAIL' : 'PASS',
|
|
37
|
+
status: deepError || provenCrashed ? 'ERROR' : quiet.speaking.length > 0 ? 'FAIL' : 'PASS',
|
|
35
38
|
findings: quiet.speaking,
|
|
36
39
|
advisory: quiet.advisory,
|
|
37
40
|
dismissed: quiet.dismissed,
|
|
41
|
+
dismissedByGate: quiet.dismissedByGate,
|
|
38
42
|
fileFindings: split.fileFindings,
|
|
39
43
|
contextFindings: split.contextFindings,
|
|
40
44
|
unlocated: split.unlocated,
|
|
41
45
|
excludedOutsideChangedLines: split.outside,
|
|
42
46
|
changedLines,
|
|
43
47
|
report,
|
|
48
|
+
gateErrors,
|
|
44
49
|
...(deepError ? { deepError } : {}),
|
|
45
50
|
};
|
|
46
51
|
}
|
|
52
|
+
function crashedGates(report) {
|
|
53
|
+
return Object.entries(report.summary).filter(([, status]) => status === 'ERROR').map(([id]) => id);
|
|
54
|
+
}
|
|
47
55
|
/** The JSON shape of a finding, shared by the CLI's --json and the MCP tool. */
|
|
48
56
|
export function toReviewFinding(failure) {
|
|
49
57
|
return {
|
|
@@ -2,7 +2,8 @@ import { execFileSync } from 'child_process';
|
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import os from 'os';
|
|
4
4
|
import path from 'path';
|
|
5
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
6
|
+
import { GateRunner } from '../gates/runner.js';
|
|
6
7
|
import { ConfigSchema } from '../types/index.js';
|
|
7
8
|
import { parseDiff } from '../utils/diff.js';
|
|
8
9
|
import { splitByChangedLines } from './changed-lines.js';
|
|
@@ -102,6 +103,29 @@ describe('git-backed review', () => {
|
|
|
102
103
|
expect(semantic.map(f => [f.files?.[0], f.line])).toEqual([['src/notify.ts', 2]]);
|
|
103
104
|
expect(toReviewFinding(semantic[0])).toMatchObject({ id: 'semantic-bugs', file: 'src/notify.ts', line: 2, severity: 'high' });
|
|
104
105
|
});
|
|
106
|
+
it('runs every gate in a git worktree, where .git is a file', async () => {
|
|
107
|
+
write('src/a.ts', 'export const a = 1;\n');
|
|
108
|
+
git('add', '-A');
|
|
109
|
+
git('commit', '-qm', 'init');
|
|
110
|
+
const worktree = path.join(repo, 'wt');
|
|
111
|
+
git('worktree', 'add', '-q', worktree);
|
|
112
|
+
fs.writeFileSync(path.join(worktree, 'src/a.ts'), 'export const a = 1;\nexport const b = 2;\n');
|
|
113
|
+
const config = ConfigSchema.parse({ version: 1, ignore: ['.git/**', 'wt/**'] });
|
|
114
|
+
const result = await reviewChange({ cwd: worktree, config });
|
|
115
|
+
expect(result.gateErrors).toEqual([]);
|
|
116
|
+
expect(result.status).not.toBe('ERROR');
|
|
117
|
+
});
|
|
118
|
+
it('is ERROR when a proven gate crashed, and only lists a crashed heuristic gate', async () => {
|
|
119
|
+
write('src/a.ts', 'export const a = 1;\n');
|
|
120
|
+
const config = ConfigSchema.parse({ version: 1 });
|
|
121
|
+
const crash = (summary) => vi.spyOn(GateRunner.prototype, 'run').mockResolvedValueOnce({ status: 'FAIL', summary, failures: [], stats: { duration_ms: 0 } });
|
|
122
|
+
crash({ 'hallucinated-imports': 'ERROR' });
|
|
123
|
+
const proven = await reviewChange({ cwd: repo, config });
|
|
124
|
+
expect([proven.status, proven.gateErrors]).toEqual(['ERROR', ['hallucinated-imports']]);
|
|
125
|
+
crash({ 'style-drift': 'ERROR', 'semantic-bugs': 'PASS' });
|
|
126
|
+
const heuristic = await reviewChange({ cwd: repo, config });
|
|
127
|
+
expect([heuristic.status, heuristic.gateErrors]).toEqual(['PASS', ['style-drift']]);
|
|
128
|
+
});
|
|
105
129
|
it('passes when nothing changed', async () => {
|
|
106
130
|
write('src/a.ts', 'export const a = 1;\n');
|
|
107
131
|
git('add', '-A');
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
type Env = Record<string, string | undefined>;
|
|
2
|
+
type Fetch = (url: string, init?: any) => Promise<unknown>;
|
|
3
|
+
export interface TelemetryState {
|
|
4
|
+
/** undefined until the person has been asked. */
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
installId: string;
|
|
7
|
+
}
|
|
8
|
+
export interface TelemetryDeps {
|
|
9
|
+
env?: Env;
|
|
10
|
+
home?: string;
|
|
11
|
+
fetch?: Fetch;
|
|
12
|
+
version?: string;
|
|
13
|
+
now?: number;
|
|
14
|
+
}
|
|
15
|
+
export declare function telemetryToken(env?: Env): string;
|
|
16
|
+
export declare function readTelemetryState(home?: string): TelemetryState;
|
|
17
|
+
export declare function setTelemetryEnabled(enabled: boolean, home?: string): TelemetryState;
|
|
18
|
+
export declare function isTelemetryEnabled(deps?: TelemetryDeps): boolean;
|
|
19
|
+
/** Ask only once, only a person at a terminal, only when sending would be possible at all. */
|
|
20
|
+
export declare function shouldAskTelemetry(isTty: boolean, deps?: TelemetryDeps): boolean;
|
|
21
|
+
export declare function trackUsage(event: string, properties: Record<string, unknown>, deps?: TelemetryDeps): Promise<void>;
|
|
22
|
+
/** Count something an agent does on a hot path (a hook check, an MCP tool call). Local only; no network. */
|
|
23
|
+
export declare function countUsage(name: string, by?: number, deps?: TelemetryDeps): void;
|
|
24
|
+
/** Send the day's counters as one `daily_usage` event once they are a day old, then start a new day. */
|
|
25
|
+
export declare function flushDailyUsage(deps?: TelemetryDeps): Promise<boolean>;
|
|
26
|
+
/** A duration as a coarse bucket, so timing never identifies a run. */
|
|
27
|
+
export declare function durationBucket(ms: number): string;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anonymous, opt-in usage telemetry: how Rigour is used and where it is wrong,
|
|
3
|
+
* so the next release fixes what people actually hit. Every field is listed in
|
|
4
|
+
* TELEMETRY.md.
|
|
5
|
+
*
|
|
6
|
+
* Never sent: code, file or repository names, paths, git remotes, finding
|
|
7
|
+
* text, emails, keys, IP addresses (Mixpanel is told not to record them). The
|
|
8
|
+
* only identity is a random install id. Nothing is sent unless the person said
|
|
9
|
+
* yes, a token is built in (release builds only), and neither DO_NOT_TRACK nor
|
|
10
|
+
* RIGOUR_TELEMETRY=0 is set. CI never sends unless RIGOUR_TELEMETRY=1. A send
|
|
11
|
+
* that fails or is slow is dropped: telemetry never fails or slows a command.
|
|
12
|
+
*
|
|
13
|
+
* Two shapes: an event per CLI command or review (`trackUsage`), and, for the
|
|
14
|
+
* hot paths an agent drives on every edit (hooks, MCP tools), local counters
|
|
15
|
+
* (`countUsage`, no network) sent as one `daily_usage` event a day.
|
|
16
|
+
*/
|
|
17
|
+
import crypto from 'crypto';
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import os from 'os';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import { MIXPANEL_TOKEN } from './token.js';
|
|
22
|
+
const ENDPOINT = 'https://api.mixpanel.com/track?ip=0';
|
|
23
|
+
const TIMEOUT_MS = 2000;
|
|
24
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
25
|
+
export function telemetryToken(env = process.env) {
|
|
26
|
+
return env.RIGOUR_MIXPANEL_TOKEN?.trim() || MIXPANEL_TOKEN;
|
|
27
|
+
}
|
|
28
|
+
function file(home, name) {
|
|
29
|
+
return path.join(home, '.rigour', name);
|
|
30
|
+
}
|
|
31
|
+
export function readTelemetryState(home = os.homedir()) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(fs.readFileSync(file(home, 'telemetry.json'), 'utf8'));
|
|
34
|
+
if (typeof parsed?.installId === 'string')
|
|
35
|
+
return { installId: parsed.installId, enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : undefined };
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Not asked yet.
|
|
39
|
+
}
|
|
40
|
+
return { installId: crypto.randomUUID() };
|
|
41
|
+
}
|
|
42
|
+
export function setTelemetryEnabled(enabled, home = os.homedir()) {
|
|
43
|
+
const state = { ...readTelemetryState(home), enabled };
|
|
44
|
+
writeJson(file(home, 'telemetry.json'), state);
|
|
45
|
+
return state;
|
|
46
|
+
}
|
|
47
|
+
function isCi(env) {
|
|
48
|
+
return !!(env.CI || env.GITHUB_ACTIONS || env.BUILDKITE || env.GITLAB_CI || env.CIRCLECI);
|
|
49
|
+
}
|
|
50
|
+
/** Opted out by the environment, whatever the stored answer. */
|
|
51
|
+
function vetoed(env) {
|
|
52
|
+
return (!!env.DO_NOT_TRACK && env.DO_NOT_TRACK !== '0') || env.RIGOUR_TELEMETRY === '0' || !telemetryToken(env);
|
|
53
|
+
}
|
|
54
|
+
export function isTelemetryEnabled(deps = {}) {
|
|
55
|
+
const env = deps.env ?? process.env;
|
|
56
|
+
if (vetoed(env))
|
|
57
|
+
return false;
|
|
58
|
+
if (env.RIGOUR_TELEMETRY === '1')
|
|
59
|
+
return true;
|
|
60
|
+
if (isCi(env))
|
|
61
|
+
return false;
|
|
62
|
+
return readTelemetryState(deps.home).enabled === true;
|
|
63
|
+
}
|
|
64
|
+
/** Ask only once, only a person at a terminal, only when sending would be possible at all. */
|
|
65
|
+
export function shouldAskTelemetry(isTty, deps = {}) {
|
|
66
|
+
const env = deps.env ?? process.env;
|
|
67
|
+
return isTty && !isCi(env) && !vetoed(env) && env.RIGOUR_TELEMETRY === undefined && readTelemetryState(deps.home).enabled === undefined;
|
|
68
|
+
}
|
|
69
|
+
export async function trackUsage(event, properties, deps = {}) {
|
|
70
|
+
if (!isTelemetryEnabled(deps))
|
|
71
|
+
return;
|
|
72
|
+
const env = deps.env ?? process.env;
|
|
73
|
+
const body = [{
|
|
74
|
+
event,
|
|
75
|
+
properties: {
|
|
76
|
+
token: telemetryToken(env),
|
|
77
|
+
distinct_id: readTelemetryState(deps.home).installId,
|
|
78
|
+
time: Math.floor((deps.now ?? Date.now()) / 1000),
|
|
79
|
+
$insert_id: crypto.randomUUID(),
|
|
80
|
+
version: deps.version,
|
|
81
|
+
os: process.platform,
|
|
82
|
+
node_major: Number(process.versions.node.split('.')[0]),
|
|
83
|
+
ci: isCi(env),
|
|
84
|
+
...properties,
|
|
85
|
+
},
|
|
86
|
+
}];
|
|
87
|
+
try {
|
|
88
|
+
await (deps.fetch ?? fetch)(ENDPOINT, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json', Accept: 'text/plain' },
|
|
91
|
+
body: JSON.stringify(body),
|
|
92
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Dropped: telemetry never affects the command.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Count something an agent does on a hot path (a hook check, an MCP tool call). Local only; no network. */
|
|
100
|
+
export function countUsage(name, by = 1, deps = {}) {
|
|
101
|
+
if (!isTelemetryEnabled(deps))
|
|
102
|
+
return;
|
|
103
|
+
try {
|
|
104
|
+
const home = deps.home ?? os.homedir();
|
|
105
|
+
const counters = readCounters(home, deps.now ?? Date.now());
|
|
106
|
+
counters.counts[name] = (counters.counts[name] ?? 0) + by;
|
|
107
|
+
writeJson(file(home, 'telemetry-counters.json'), counters);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// Counting never affects the agent.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** Send the day's counters as one `daily_usage` event once they are a day old, then start a new day. */
|
|
114
|
+
export async function flushDailyUsage(deps = {}) {
|
|
115
|
+
if (!isTelemetryEnabled(deps))
|
|
116
|
+
return false;
|
|
117
|
+
const home = deps.home ?? os.homedir();
|
|
118
|
+
const now = deps.now ?? Date.now();
|
|
119
|
+
const counters = readCounters(home, now);
|
|
120
|
+
if (now - counters.since < DAY_MS || Object.keys(counters.counts).length === 0)
|
|
121
|
+
return false;
|
|
122
|
+
writeJson(file(home, 'telemetry-counters.json'), { since: now, counts: {} });
|
|
123
|
+
await trackUsage('daily_usage', { ...counters.counts }, deps);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
function readCounters(home, now) {
|
|
127
|
+
try {
|
|
128
|
+
const parsed = JSON.parse(fs.readFileSync(file(home, 'telemetry-counters.json'), 'utf8'));
|
|
129
|
+
if (typeof parsed?.since === 'number' && parsed.counts && typeof parsed.counts === 'object')
|
|
130
|
+
return parsed;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// First count.
|
|
134
|
+
}
|
|
135
|
+
return { since: now, counts: {} };
|
|
136
|
+
}
|
|
137
|
+
function writeJson(target, value) {
|
|
138
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
139
|
+
fs.writeFileSync(target, JSON.stringify(value, null, 2) + '\n');
|
|
140
|
+
}
|
|
141
|
+
/** A duration as a coarse bucket, so timing never identifies a run. */
|
|
142
|
+
export function durationBucket(ms) {
|
|
143
|
+
if (ms < 1000)
|
|
144
|
+
return '<1s';
|
|
145
|
+
if (ms < 5000)
|
|
146
|
+
return '1-5s';
|
|
147
|
+
if (ms < 30000)
|
|
148
|
+
return '5-30s';
|
|
149
|
+
if (ms < 120000)
|
|
150
|
+
return '30s-2m';
|
|
151
|
+
return '>2m';
|
|
152
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { countUsage, durationBucket, flushDailyUsage, isTelemetryEnabled, readTelemetryState, setTelemetryEnabled, shouldAskTelemetry, trackUsage } from './telemetry.js';
|
|
6
|
+
let home;
|
|
7
|
+
const env = { RIGOUR_MIXPANEL_TOKEN: 'tok' };
|
|
8
|
+
beforeEach(() => { home = fs.mkdtempSync(path.join(os.tmpdir(), 'telemetry-')); });
|
|
9
|
+
afterEach(() => { fs.rmSync(home, { recursive: true, force: true }); });
|
|
10
|
+
describe('telemetry consent', () => {
|
|
11
|
+
it('sends nothing until the person says yes, and the environment always wins', () => {
|
|
12
|
+
expect(isTelemetryEnabled({ env, home })).toBe(false);
|
|
13
|
+
expect(shouldAskTelemetry(true, { env, home })).toBe(true);
|
|
14
|
+
setTelemetryEnabled(true, home);
|
|
15
|
+
expect(isTelemetryEnabled({ env, home })).toBe(true);
|
|
16
|
+
expect(shouldAskTelemetry(true, { env, home })).toBe(false); // asked once
|
|
17
|
+
expect(isTelemetryEnabled({ env: { ...env, DO_NOT_TRACK: '1' }, home })).toBe(false);
|
|
18
|
+
expect(isTelemetryEnabled({ env: { ...env, RIGOUR_TELEMETRY: '0' }, home })).toBe(false);
|
|
19
|
+
expect(isTelemetryEnabled({ env: { ...env, CI: 'true' }, home })).toBe(false);
|
|
20
|
+
expect(isTelemetryEnabled({ env: {}, home })).toBe(false); // a build without a token never sends
|
|
21
|
+
});
|
|
22
|
+
it('never asks in CI or without a terminal, and keeps one random install id', () => {
|
|
23
|
+
expect(shouldAskTelemetry(false, { env, home })).toBe(false);
|
|
24
|
+
expect(shouldAskTelemetry(true, { env: { ...env, GITHUB_ACTIONS: 'true' }, home })).toBe(false);
|
|
25
|
+
const first = setTelemetryEnabled(false, home).installId;
|
|
26
|
+
expect(readTelemetryState(home).installId).toBe(first);
|
|
27
|
+
expect(first).toMatch(/^[0-9a-f-]{36}$/);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe('telemetry sending', () => {
|
|
31
|
+
it('sends one event with the install id and no IP, and swallows a failing network', async () => {
|
|
32
|
+
setTelemetryEnabled(true, home);
|
|
33
|
+
const fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
34
|
+
await trackUsage('review_completed', { status: 'PASS', findings_by_gate: { 'semantic-bugs': 1 } }, { env, home, fetch, version: '6.5.0' });
|
|
35
|
+
const [url, init] = fetch.mock.calls[0];
|
|
36
|
+
expect(url).toBe('https://api.mixpanel.com/track?ip=0');
|
|
37
|
+
const [event] = JSON.parse(init.body);
|
|
38
|
+
expect(event.event).toBe('review_completed');
|
|
39
|
+
expect(event.properties).toMatchObject({ token: 'tok', distinct_id: readTelemetryState(home).installId, version: '6.5.0', status: 'PASS' });
|
|
40
|
+
expect(JSON.stringify(event)).not.toContain(home);
|
|
41
|
+
await expect(trackUsage('x', {}, { env, home, fetch: vi.fn().mockRejectedValue(new Error('offline')) })).resolves.toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
it('counts hot-path activity locally and sends it as one daily event', async () => {
|
|
44
|
+
setTelemetryEnabled(true, home);
|
|
45
|
+
const fetch = vi.fn().mockResolvedValue({ ok: true });
|
|
46
|
+
const day = 24 * 60 * 60 * 1000;
|
|
47
|
+
countUsage('hook_check', 1, { env, home, now: 0 });
|
|
48
|
+
countUsage('hook_check', 1, { env, home, now: 1000 });
|
|
49
|
+
countUsage('mcp:rigour_review', 1, { env, home, now: 2000 });
|
|
50
|
+
expect(await flushDailyUsage({ env, home, fetch, now: 5000 })).toBe(false); // not a day yet
|
|
51
|
+
expect(await flushDailyUsage({ env, home, fetch, now: day + 1 })).toBe(true);
|
|
52
|
+
const [event] = JSON.parse(fetch.mock.calls[0][1].body);
|
|
53
|
+
expect(event).toMatchObject({ event: 'daily_usage', properties: { hook_check: 2, 'mcp:rigour_review': 1 } });
|
|
54
|
+
expect(await flushDailyUsage({ env, home, fetch, now: 2 * day + 2 })).toBe(false); // counters reset
|
|
55
|
+
});
|
|
56
|
+
it('buckets durations so timing never identifies a run', () => {
|
|
57
|
+
expect([500, 3000, 20000, 60000, 600000].map(durationBucket)).toEqual(['<1s', '1-5s', '5-30s', '30s-2m', '>2m']);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const MIXPANEL_TOKEN = "53ab28669fd4bf80e2dbe5605eabacc5";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const MIXPANEL_TOKEN = '53ab28669fd4bf80e2dbe5605eabacc5';
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Ignore entries: a plain path also ignores everything under it. Matched only, never listed. */
|
|
2
|
+
export declare function withDirectoryForms(patterns: string[]): string[];
|
|
3
|
+
/** Search patterns: a plain path that is a directory becomes `dir/**`; files and globs stay. */
|
|
4
|
+
export declare function directoriesAsGlobs(cwd: string, patterns: string[]): string[];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { isDynamicPattern } from 'globby';
|
|
4
|
+
/*
|
|
5
|
+
* globby's default directory expansion stats every pattern and ignore entry,
|
|
6
|
+
* and throws ENOTDIR when one runs through a file: `.git/**` in a git
|
|
7
|
+
* worktree, where `.git` is a file. Rigour turns it off
|
|
8
|
+
* (`expandDirectories: false`) and expands plain paths itself, as before:
|
|
9
|
+
* a directory `legacy` also means `legacy/**`.
|
|
10
|
+
*/
|
|
11
|
+
/** Ignore entries: a plain path also ignores everything under it. Matched only, never listed. */
|
|
12
|
+
export function withDirectoryForms(patterns) {
|
|
13
|
+
return patterns.flatMap(pattern => isDynamicPattern(pattern) ? [pattern] : [pattern, `${pattern.replace(/\/+$/, '')}/**`]);
|
|
14
|
+
}
|
|
15
|
+
/** Search patterns: a plain path that is a directory becomes `dir/**`; files and globs stay. */
|
|
16
|
+
export function directoriesAsGlobs(cwd, patterns) {
|
|
17
|
+
return patterns.map(pattern => !isDynamicPattern(pattern) && isDirectory(path.resolve(cwd, pattern)) ? `${pattern.replace(/\/+$/, '')}/**` : pattern);
|
|
18
|
+
}
|
|
19
|
+
function isDirectory(target) {
|
|
20
|
+
try {
|
|
21
|
+
return fs.statSync(target).isDirectory();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
|
+
import { FileScanner } from './scanner.js';
|
|
6
|
+
import { directoriesAsGlobs, withDirectoryForms } from './glob-paths.js';
|
|
7
|
+
let root;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), 'glob-paths-'));
|
|
10
|
+
fs.mkdirSync(path.join(root, 'src'));
|
|
11
|
+
fs.mkdirSync(path.join(root, 'legacy'));
|
|
12
|
+
fs.writeFileSync(path.join(root, 'src', 'a.ts'), 'export const a = 1;\n');
|
|
13
|
+
fs.writeFileSync(path.join(root, 'legacy', 'old.ts'), 'export const b = 2;\n');
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
16
|
+
describe('withDirectoryForms', () => {
|
|
17
|
+
it('adds the directory form to plain paths and leaves globs alone', () => {
|
|
18
|
+
expect(withDirectoryForms(['legacy', 'build/', '.git/**', '**/*.min.js'])).toEqual(['legacy', 'legacy/**', 'build/', 'build/**', '.git/**', '**/*.min.js']);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
describe('directoriesAsGlobs', () => {
|
|
22
|
+
it('turns a directory into dir/** and leaves files, missing paths and globs alone', () => {
|
|
23
|
+
expect(directoriesAsGlobs(root, ['src', 'src/a.ts', 'missing', '**/*.ts'])).toEqual(['src/**', 'src/a.ts', 'missing', '**/*.ts']);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
describe('FileScanner in a git worktree', () => {
|
|
27
|
+
it('scans when .git is a file and the config ignores .git/**', async () => {
|
|
28
|
+
fs.writeFileSync(path.join(root, '.git'), 'gitdir: /elsewhere/.git/worktrees/x\n');
|
|
29
|
+
const files = await FileScanner.findFiles({ cwd: root, patterns: ['**/*.ts'], ignore: ['.git/**'] });
|
|
30
|
+
expect(files.sort()).toEqual(['legacy/old.ts', 'src/a.ts']);
|
|
31
|
+
});
|
|
32
|
+
it('finds a file or a whole directory named by a plain path', async () => {
|
|
33
|
+
expect(await FileScanner.findFiles({ cwd: root, patterns: ['src/a.ts'] })).toEqual(['src/a.ts']);
|
|
34
|
+
expect(await FileScanner.findFiles({ cwd: root, patterns: ['legacy'] })).toEqual(['legacy/old.ts']);
|
|
35
|
+
});
|
|
36
|
+
it('still ignores a whole directory named by a plain path', async () => {
|
|
37
|
+
const files = await FileScanner.findFiles({ cwd: root, patterns: ['**/*.ts'], ignore: ['legacy'] });
|
|
38
|
+
expect(files).toEqual(['src/a.ts']);
|
|
39
|
+
});
|
|
40
|
+
});
|
package/dist/utils/scanner.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { globby } from 'globby';
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import { directoriesAsGlobs, withDirectoryForms } from './glob-paths.js';
|
|
4
5
|
/**
|
|
5
6
|
* Glob patterns use `/` as the separator and `\` as the escape character.
|
|
6
7
|
* Only on Windows can a backslash be a path separator, and even there one
|
|
@@ -33,9 +34,10 @@ export class FileScanner {
|
|
|
33
34
|
const userIgnore = options.ignore || [];
|
|
34
35
|
const ignore = [...new Set([...this.DEFAULT_IGNORE, ...userIgnore])].map(toGlobSeparators);
|
|
35
36
|
const normalizedCwd = options.cwd.replace(/\\/g, '/');
|
|
36
|
-
return globby(patterns, {
|
|
37
|
+
return globby(directoriesAsGlobs(normalizedCwd, patterns), {
|
|
37
38
|
cwd: normalizedCwd,
|
|
38
|
-
ignore: ignore,
|
|
39
|
+
ignore: withDirectoryForms(ignore),
|
|
40
|
+
expandDirectories: false,
|
|
39
41
|
});
|
|
40
42
|
}
|
|
41
43
|
/**
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
2
|
import { FileScanner } from './scanner.js';
|
|
3
3
|
import { globby } from 'globby';
|
|
4
|
-
vi.mock('globby', () => ({
|
|
4
|
+
vi.mock('globby', async (importOriginal) => ({
|
|
5
|
+
isDynamicPattern: (await importOriginal()).isDynamicPattern,
|
|
5
6
|
globby: vi.fn(),
|
|
6
7
|
}));
|
|
7
8
|
describe('FileScanner', () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigour-labs/core",
|
|
3
|
-
"version": "6.5.
|
|
3
|
+
"version": "6.5.2",
|
|
4
4
|
"description": "AI-native quality gate engine with local Bayesian learning. AST analysis, drift detection, Fix Packet generation, and agent self-healing across TypeScript, JavaScript, Python, Go, Ruby, and C#.",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22"
|
|
@@ -70,11 +70,11 @@
|
|
|
70
70
|
"pg": "^8.16.3",
|
|
71
71
|
"sqlite3": "^5.1.7",
|
|
72
72
|
"openai": "^4.104.0",
|
|
73
|
-
"@rigour-labs/brain-darwin-arm64": "6.5.
|
|
74
|
-
"@rigour-labs/brain-linux-arm64": "6.5.
|
|
75
|
-
"@rigour-labs/brain-darwin-x64": "6.5.
|
|
76
|
-
"@rigour-labs/brain-
|
|
77
|
-
"@rigour-labs/brain-
|
|
73
|
+
"@rigour-labs/brain-darwin-arm64": "6.5.2",
|
|
74
|
+
"@rigour-labs/brain-linux-arm64": "6.5.2",
|
|
75
|
+
"@rigour-labs/brain-darwin-x64": "6.5.2",
|
|
76
|
+
"@rigour-labs/brain-win-x64": "6.5.2",
|
|
77
|
+
"@rigour-labs/brain-linux-x64": "6.5.2"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@types/fs-extra": "^11.0.4",
|