@rigour-labs/core 6.5.0 → 6.5.1

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/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';
@@ -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>;
@@ -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
  });
@@ -21,8 +21,9 @@ 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>>;
@@ -21,7 +21,7 @@ export async function reviewChange(input) {
21
21
  const changedLines = withoutGenerated(input.cwd, parseDiff(diff));
22
22
  const targets = input.files?.length ? input.files : Object.keys(changedLines);
23
23
  if (targets.length === 0) {
24
- return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], dismissed: 0, unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null };
24
+ return { status: 'PASS', findings: [], fileFindings: [], contextFindings: [], advisory: [], dismissed: 0, dismissedByGate: {}, unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null };
25
25
  }
26
26
  const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines), removedLines: removedByFile(diff), diff } : undefined;
27
27
  const report = await new GateRunner(input.config).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
@@ -35,6 +35,7 @@ export async function reviewChange(input) {
35
35
  findings: quiet.speaking,
36
36
  advisory: quiet.advisory,
37
37
  dismissed: quiet.dismissed,
38
+ dismissedByGate: quiet.dismissedByGate,
38
39
  fileFindings: split.fileFindings,
39
40
  contextFindings: split.contextFindings,
40
41
  unlocated: split.unlocated,
@@ -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';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigour-labs/core",
3
- "version": "6.5.0",
3
+ "version": "6.5.1",
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.0",
74
- "@rigour-labs/brain-linux-arm64": "6.5.0",
75
- "@rigour-labs/brain-darwin-x64": "6.5.0",
76
- "@rigour-labs/brain-linux-x64": "6.5.0",
77
- "@rigour-labs/brain-win-x64": "6.5.0"
73
+ "@rigour-labs/brain-darwin-arm64": "6.5.1",
74
+ "@rigour-labs/brain-linux-arm64": "6.5.1",
75
+ "@rigour-labs/brain-darwin-x64": "6.5.1",
76
+ "@rigour-labs/brain-linux-x64": "6.5.1",
77
+ "@rigour-labs/brain-win-x64": "6.5.1"
78
78
  },
79
79
  "devDependencies": {
80
80
  "@types/fs-extra": "^11.0.4",