@rigour-labs/core 6.5.4 → 6.6.0
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/context/automatic-index-cache.test.d.ts +1 -0
- package/dist/context/automatic-index-cache.test.js +30 -0
- package/dist/context/automatic-index.js +0 -4
- package/dist/context/cache-engine.d.ts +7 -38
- package/dist/context/cache-engine.js +7 -70
- package/dist/context/cache-engine.test.js +2 -46
- package/dist/context/index-bridge.d.ts +1 -7
- package/dist/context/index-bridge.js +1 -79
- package/dist/firewall/execution-receipt.d.ts +5 -0
- package/dist/firewall/execution-receipt.js +10 -1
- package/dist/firewall/trusted-control.test.js +10 -0
- package/dist/hooks/checker.js +18 -0
- package/dist/hooks/checker.test.js +10 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- package/dist/review/agent-fixes.d.ts +25 -2
- package/dist/review/agent-fixes.js +27 -6
- package/dist/review/effectiveness.d.ts +13 -0
- package/dist/review/learning-events.d.ts +14 -0
- package/dist/review/learning-events.js +21 -0
- package/dist/review/stories.d.ts +25 -0
- package/dist/review/stories.js +75 -0
- package/dist/review/stories.test.d.ts +1 -0
- package/dist/review/stories.test.js +58 -0
- package/dist/storage/cache-cleanup.d.ts +19 -0
- package/dist/storage/cache-cleanup.js +90 -0
- package/dist/storage/cache-cleanup.test.d.ts +1 -0
- package/dist/storage/cache-cleanup.test.js +54 -0
- package/dist/storage/db.js +3 -1
- package/dist/storage/index.d.ts +1 -0
- package/dist/storage/index.js +1 -0
- package/dist/storage/local-encryption.js +2 -2
- package/dist/storage/team-store.js +2 -2
- package/dist/utils/user-state.js +1 -1
- package/package.json +6 -6
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
const writes = vi.hoisted(() => ({ count: 0 }));
|
|
6
|
+
vi.mock('../storage/context-telemetry.js', async (importOriginal) => ({
|
|
7
|
+
...(await importOriginal()),
|
|
8
|
+
setContextCacheRecord: async () => { writes.count++; },
|
|
9
|
+
}));
|
|
10
|
+
const { ensureAutomaticIndex, updateAutomaticIndexForFiles } = await import('./automatic-index.js');
|
|
11
|
+
let cwd;
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
writes.count = 0;
|
|
14
|
+
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'auto-index-'));
|
|
15
|
+
fs.mkdirSync(path.join(cwd, 'src'));
|
|
16
|
+
fs.writeFileSync(path.join(cwd, 'src/a.ts'), 'export function alpha() { return 1; }\n');
|
|
17
|
+
fs.writeFileSync(path.join(cwd, 'src/b.ts'), 'export function beta() { return 2; }\n');
|
|
18
|
+
});
|
|
19
|
+
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
20
|
+
describe('automatic indexing', () => {
|
|
21
|
+
it('writes nothing to the context cache, however many edits it sees', async () => {
|
|
22
|
+
await ensureAutomaticIndex(cwd, { backgroundSemantic: false });
|
|
23
|
+
for (let edit = 0; edit < 5; edit++) {
|
|
24
|
+
fs.writeFileSync(path.join(cwd, 'src/a.ts'), `export function alpha() { return ${edit}; }\n`);
|
|
25
|
+
await updateAutomaticIndexForFiles(cwd, ['src/a.ts']);
|
|
26
|
+
}
|
|
27
|
+
expect(writes.count).toBe(0);
|
|
28
|
+
expect(fs.existsSync(path.join(cwd, '.rigour', 'patterns.json'))).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -3,7 +3,6 @@ import path from 'path';
|
|
|
3
3
|
import { PatternIndexer, getDefaultIndexPath, loadPatternIndex, savePatternIndex } from '../pattern-index/indexer.js';
|
|
4
4
|
import { EMBEDDING_TEXT_VERSION } from '../pattern-index/embeddings.js';
|
|
5
5
|
import { affectedDependents, buildDependencyGraph, updateDependencyGraph } from './dependency-graph.js';
|
|
6
|
-
import { syncIndexToContextCache } from './index-bridge.js';
|
|
7
6
|
const enrichmentJobs = new Map();
|
|
8
7
|
function projectRelativeFiles(cwd, files) {
|
|
9
8
|
const root = path.resolve(cwd);
|
|
@@ -78,7 +77,6 @@ async function enrichSemantic(cwd, index) {
|
|
|
78
77
|
const merged = mergeSemanticEmbeddings(latest, enriched);
|
|
79
78
|
const embedded = merged.patterns.filter((pattern) => (pattern.embedding?.length ?? 0) > 0).length;
|
|
80
79
|
await savePatternIndex(merged, indexPath);
|
|
81
|
-
await syncIndexToContextCache(cwd, merged);
|
|
82
80
|
await writeStatus(cwd, {
|
|
83
81
|
structural: 'ready',
|
|
84
82
|
graph: 'ready',
|
|
@@ -114,7 +112,6 @@ export async function ensureAutomaticIndex(cwd, options = {}) {
|
|
|
114
112
|
const index = existing ?? await indexer.buildIndex();
|
|
115
113
|
if (!existing)
|
|
116
114
|
await savePatternIndex(index, indexPath);
|
|
117
|
-
await syncIndexToContextCache(cwd, index);
|
|
118
115
|
if (!(await fs.pathExists(path.join(cwd, '.rigour', 'dependency-graph.json')))) {
|
|
119
116
|
await buildDependencyGraph(cwd, index);
|
|
120
117
|
}
|
|
@@ -144,7 +141,6 @@ export async function updateAutomaticIndexForFiles(cwd, changedFiles) {
|
|
|
144
141
|
const indexer = new PatternIndexer(cwd, { useEmbeddings: false });
|
|
145
142
|
const index = await indexer.updateFiles(existing, affectedFiles);
|
|
146
143
|
await savePatternIndex(index, getDefaultIndexPath(cwd));
|
|
147
|
-
await syncIndexToContextCache(cwd, index);
|
|
148
144
|
if (graph)
|
|
149
145
|
await updateDependencyGraph(cwd, index, graph, affectedFiles);
|
|
150
146
|
else
|
|
@@ -1,32 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Context caching for Rigour: the two layers something reads.
|
|
3
|
+
* - Semantic-query cache: the edit and validation scope resolved for a task query.
|
|
4
|
+
* - Task checkpoint cache: compact handoff packets between agents.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 2. Component context cache (Compact dossiers per component scope)
|
|
7
|
-
* 3. Semantic-query cache (Intent embeddings & edit/validation scopes)
|
|
8
|
-
* 4. Task checkpoint cache (Subagent handoff packets)
|
|
6
|
+
* The static (per-file) and component layers were removed: nothing read them, and the index sync
|
|
7
|
+
* that wrote them added a fresh row per component on every edit (millions of rows in real use).
|
|
9
8
|
*/
|
|
10
|
-
export interface StaticCacheEntry {
|
|
11
|
-
astSummary?: any;
|
|
12
|
-
exports?: string[];
|
|
13
|
-
imports?: string[];
|
|
14
|
-
dependencies?: string[];
|
|
15
|
-
ownership?: string;
|
|
16
|
-
schemas?: any[];
|
|
17
|
-
endpoints?: string[];
|
|
18
|
-
eventRelationships?: any[];
|
|
19
|
-
styleFingerprint?: string;
|
|
20
|
-
rigourPatterns?: string[];
|
|
21
|
-
}
|
|
22
|
-
export interface ComponentDossier {
|
|
23
|
-
component: string;
|
|
24
|
-
responsibility: string;
|
|
25
|
-
canonicalFiles: string[];
|
|
26
|
-
contracts: string[];
|
|
27
|
-
directConsumers: string[];
|
|
28
|
-
validationCommands: string[];
|
|
29
|
-
}
|
|
30
9
|
export interface SemanticQueryEntry {
|
|
31
10
|
query: string;
|
|
32
11
|
intentEmbedding?: number[];
|
|
@@ -78,17 +57,7 @@ export interface TaskCheckpointPacket {
|
|
|
78
57
|
*/
|
|
79
58
|
export declare function hashContent(content: string): string;
|
|
80
59
|
/**
|
|
81
|
-
*
|
|
82
|
-
*/
|
|
83
|
-
export declare function getStaticCache(repo: string, branch: string, filePath: string, fileContent: string, cwd?: string): Promise<StaticCacheEntry | null>;
|
|
84
|
-
export declare function setStaticCache(repo: string, branch: string, filePath: string, fileContent: string, entry: StaticCacheEntry, cwd?: string): Promise<void>;
|
|
85
|
-
/**
|
|
86
|
-
* Layer 2: Component Context Cache
|
|
87
|
-
*/
|
|
88
|
-
export declare function getComponentCache(componentName: string, commitSha: string, profileVersion?: string, cwd?: string): Promise<ComponentDossier | null>;
|
|
89
|
-
export declare function setComponentCache(componentName: string, commitSha: string, dossier: ComponentDossier, dependencyFingerprint: string, profileVersion?: string, cwd?: string): Promise<void>;
|
|
90
|
-
/**
|
|
91
|
-
* Layer 3: Semantic-Query Cache
|
|
60
|
+
* Semantic-Query Cache
|
|
92
61
|
*/
|
|
93
62
|
export declare function normalizeQuery(query: string): string;
|
|
94
63
|
export declare function getSemanticQueryCache(query: string, commitSha: string, cwd?: string): Promise<SemanticQueryEntry | null>;
|
|
@@ -119,7 +88,7 @@ export declare function filterExistingEditScope(editScope: string[], cwd: string
|
|
|
119
88
|
missing: string[];
|
|
120
89
|
}>;
|
|
121
90
|
/**
|
|
122
|
-
*
|
|
91
|
+
* Task Checkpoint Cache
|
|
123
92
|
*/
|
|
124
93
|
export declare function setTaskCheckpointCache(packet: TaskCheckpointPacket, rawStateTokens: number, cwd?: string): Promise<void>;
|
|
125
94
|
export declare function getTaskCheckpointCache(taskId: string, agentId: string, phase: string, cwd?: string): Promise<TaskCheckpointPacket | null>;
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Context caching for Rigour: the two layers something reads.
|
|
3
|
+
* - Semantic-query cache: the edit and validation scope resolved for a task query.
|
|
4
|
+
* - Task checkpoint cache: compact handoff packets between agents.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* 2. Component context cache (Compact dossiers per component scope)
|
|
7
|
-
* 3. Semantic-query cache (Intent embeddings & edit/validation scopes)
|
|
8
|
-
* 4. Task checkpoint cache (Subagent handoff packets)
|
|
6
|
+
* The static (per-file) and component layers were removed: nothing read them, and the index sync
|
|
7
|
+
* that wrote them added a fresh row per component on every edit (millions of rows in real use).
|
|
9
8
|
*/
|
|
10
9
|
import { createHash } from 'crypto';
|
|
11
10
|
import fs from 'fs-extra';
|
|
@@ -18,69 +17,7 @@ export function hashContent(content) {
|
|
|
18
17
|
return createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
19
18
|
}
|
|
20
19
|
/**
|
|
21
|
-
*
|
|
22
|
-
*/
|
|
23
|
-
export async function getStaticCache(repo, branch, filePath, fileContent, cwd) {
|
|
24
|
-
const contentSha = hashContent(fileContent);
|
|
25
|
-
const cacheKey = `static:${repo}:${branch}:${filePath}:${contentSha}:v1`;
|
|
26
|
-
const record = await getContextCacheRecord(cacheKey, cwd);
|
|
27
|
-
if (!record)
|
|
28
|
-
return null;
|
|
29
|
-
try {
|
|
30
|
-
return JSON.parse(record.payloadJson);
|
|
31
|
-
}
|
|
32
|
-
catch {
|
|
33
|
-
return null;
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
export async function setStaticCache(repo, branch, filePath, fileContent, entry, cwd) {
|
|
37
|
-
const contentSha = hashContent(fileContent);
|
|
38
|
-
const cacheKey = `static:${repo}:${branch}:${filePath}:${contentSha}:v1`;
|
|
39
|
-
const payloadJson = JSON.stringify(entry);
|
|
40
|
-
const payloadTokens = Math.ceil(payloadJson.length / 4);
|
|
41
|
-
await setContextCacheRecord({
|
|
42
|
-
cacheKey,
|
|
43
|
-
cacheType: 'static',
|
|
44
|
-
repo,
|
|
45
|
-
branch,
|
|
46
|
-
commitSha: contentSha,
|
|
47
|
-
dependencyFingerprint: `sha-${contentSha}`,
|
|
48
|
-
payloadJson,
|
|
49
|
-
payloadTokens,
|
|
50
|
-
}, cwd);
|
|
51
|
-
}
|
|
52
|
-
/**
|
|
53
|
-
* Layer 2: Component Context Cache
|
|
54
|
-
*/
|
|
55
|
-
export async function getComponentCache(componentName, commitSha, profileVersion = '3', cwd) {
|
|
56
|
-
const cacheKey = `component:${componentName}:${commitSha}:v${profileVersion}`;
|
|
57
|
-
const record = await getContextCacheRecord(cacheKey, cwd);
|
|
58
|
-
if (!record)
|
|
59
|
-
return null;
|
|
60
|
-
try {
|
|
61
|
-
return JSON.parse(record.payloadJson);
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
export async function setComponentCache(componentName, commitSha, dossier, dependencyFingerprint, profileVersion = '3', cwd) {
|
|
68
|
-
const cacheKey = `component:${componentName}:${commitSha}:v${profileVersion}`;
|
|
69
|
-
const payloadJson = JSON.stringify(dossier);
|
|
70
|
-
const payloadTokens = Math.ceil(payloadJson.length / 4);
|
|
71
|
-
await setContextCacheRecord({
|
|
72
|
-
cacheKey,
|
|
73
|
-
cacheType: 'component',
|
|
74
|
-
repo: dossier.component,
|
|
75
|
-
branch: 'main',
|
|
76
|
-
commitSha,
|
|
77
|
-
dependencyFingerprint,
|
|
78
|
-
payloadJson,
|
|
79
|
-
payloadTokens,
|
|
80
|
-
}, cwd);
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Layer 3: Semantic-Query Cache
|
|
20
|
+
* Semantic-Query Cache
|
|
84
21
|
*/
|
|
85
22
|
export function normalizeQuery(query) {
|
|
86
23
|
return query.toLowerCase().trim().replace(/[^a-z0-9\s]/g, '').replace(/\s+/g, ' ');
|
|
@@ -193,7 +130,7 @@ export async function filterExistingEditScope(editScope, cwd) {
|
|
|
193
130
|
return { valid, missing };
|
|
194
131
|
}
|
|
195
132
|
/**
|
|
196
|
-
*
|
|
133
|
+
* Task Checkpoint Cache
|
|
197
134
|
*/
|
|
198
135
|
export async function setTaskCheckpointCache(packet, rawStateTokens, cwd) {
|
|
199
136
|
const cacheKey = `checkpoint:${packet.taskId}:${packet.agentId}:${packet.phase}`;
|
|
@@ -8,8 +8,8 @@ vi.mock('./db.js', () => ({
|
|
|
8
8
|
DB_PATH: '/tmp/rigour-test.db',
|
|
9
9
|
RIGOUR_DIR: '/tmp/.rigour',
|
|
10
10
|
}));
|
|
11
|
-
import {
|
|
12
|
-
describe('
|
|
11
|
+
import { getSemanticQueryCache, setSemanticQueryCache, findRelatedSemanticQueryCache, queryTokenOverlap, distinctiveTokenAgreement, filterExistingEditScope, getTaskCheckpointCache, setTaskCheckpointCache, hashContent, normalizeQuery } from './cache-engine.js';
|
|
12
|
+
describe('Context cache engine', () => {
|
|
13
13
|
const testCwd = path.join(os.tmpdir(), `rigour-cache-test-${Date.now()}`);
|
|
14
14
|
beforeEach(async () => {
|
|
15
15
|
await fs.ensureDir(testCwd);
|
|
@@ -26,35 +26,6 @@ describe('4-Layer Context Cache Engine', () => {
|
|
|
26
26
|
expect(normalizeQuery(' Add priority to task!! ')).toBe('add priority to task');
|
|
27
27
|
expect(normalizeQuery('Task SHOULD Support Priority.')).toBe('task should support priority');
|
|
28
28
|
});
|
|
29
|
-
it('Layer 1: should store and retrieve content-addressed static cache', async () => {
|
|
30
|
-
const fileContent = 'export function processTask() { return true; }';
|
|
31
|
-
const entry = {
|
|
32
|
-
exports: ['processTask'],
|
|
33
|
-
imports: [],
|
|
34
|
-
ownership: 'platform-team',
|
|
35
|
-
endpoints: ['/api/task']
|
|
36
|
-
};
|
|
37
|
-
await setStaticCache('rigour-repo', 'main', 'services/task.ts', fileContent, entry, testCwd);
|
|
38
|
-
const cached = await getStaticCache('rigour-repo', 'main', 'services/task.ts', fileContent, testCwd);
|
|
39
|
-
expect(cached).not.toBeNull();
|
|
40
|
-
expect(cached?.exports).toEqual(['processTask']);
|
|
41
|
-
expect(cached?.ownership).toBe('platform-team');
|
|
42
|
-
});
|
|
43
|
-
it('Layer 2: should store and retrieve component context dossier', async () => {
|
|
44
|
-
const dossier = {
|
|
45
|
-
component: 'services/task',
|
|
46
|
-
responsibility: 'Task lifecycle and persistence',
|
|
47
|
-
canonicalFiles: ['services/task.ts'],
|
|
48
|
-
contracts: ['TaskContract'],
|
|
49
|
-
directConsumers: ['controllers/task'],
|
|
50
|
-
validationCommands: ['npm test']
|
|
51
|
-
};
|
|
52
|
-
await setComponentCache('services/task', 'abc123commit', dossier, 'dep-fingerprint-v1', '3', testCwd);
|
|
53
|
-
const retrieved = await getComponentCache('services/task', 'abc123commit', '3', testCwd);
|
|
54
|
-
expect(retrieved).not.toBeNull();
|
|
55
|
-
expect(retrieved?.responsibility).toBe('Task lifecycle and persistence');
|
|
56
|
-
expect(retrieved?.canonicalFiles).toContain('services/task.ts');
|
|
57
|
-
});
|
|
58
29
|
it('Layer 3: should store and retrieve semantic query scope', async () => {
|
|
59
30
|
const entry = {
|
|
60
31
|
query: 'task priority persistence',
|
|
@@ -125,19 +96,4 @@ describe('4-Layer Context Cache Engine', () => {
|
|
|
125
96
|
expect(result.valid).toEqual(['alive.ts']);
|
|
126
97
|
expect(result.missing).toEqual(['gone.ts']);
|
|
127
98
|
});
|
|
128
|
-
it('Layer 1: invalidates static cache when file content changes', async () => {
|
|
129
|
-
const originalContent = 'export function processTask() { return true; }';
|
|
130
|
-
const entry = {
|
|
131
|
-
exports: ['processTask'],
|
|
132
|
-
imports: [],
|
|
133
|
-
ownership: 'platform-team',
|
|
134
|
-
endpoints: ['/api/task'],
|
|
135
|
-
};
|
|
136
|
-
await setStaticCache('rigour-repo', 'main', 'services/task.ts', originalContent, entry, testCwd);
|
|
137
|
-
const cached = await getStaticCache('rigour-repo', 'main', 'services/task.ts', originalContent, testCwd);
|
|
138
|
-
expect(cached?.exports).toEqual(['processTask']);
|
|
139
|
-
const changedContent = 'export function processTask() { return false; }';
|
|
140
|
-
const stale = await getStaticCache('rigour-repo', 'main', 'services/task.ts', changedContent, testCwd);
|
|
141
|
-
expect(stale).toBeNull();
|
|
142
|
-
});
|
|
143
99
|
});
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* Syncs indexed patterns into static cache entries and component dossiers.
|
|
2
|
+
* Health of the pattern index (.rigour/patterns.json): present, readable, fresh.
|
|
4
3
|
*/
|
|
5
|
-
import type { PatternIndex } from '../pattern-index/types.js';
|
|
6
4
|
export interface IndexHealthReport {
|
|
7
5
|
indexPath: string;
|
|
8
6
|
exists: boolean;
|
|
@@ -14,10 +12,6 @@ export interface IndexHealthReport {
|
|
|
14
12
|
totalFiles?: number;
|
|
15
13
|
message: string;
|
|
16
14
|
}
|
|
17
|
-
/**
|
|
18
|
-
* Sync pattern index entries into static cache and component dossier layers.
|
|
19
|
-
*/
|
|
20
|
-
export declare function syncIndexToContextCache(cwd: string, index: PatternIndex): Promise<number>;
|
|
21
15
|
/**
|
|
22
16
|
* Check patterns.json freshness relative to the project directory.
|
|
23
17
|
*/
|
|
@@ -1,90 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* Syncs indexed patterns into static cache entries and component dossiers.
|
|
2
|
+
* Health of the pattern index (.rigour/patterns.json): present, readable, fresh.
|
|
4
3
|
*/
|
|
5
4
|
import fs from 'fs-extra';
|
|
6
5
|
import path from 'path';
|
|
7
|
-
import { setStaticCache, setComponentCache, hashContent } from './cache-engine.js';
|
|
8
|
-
import { getRepositoryId } from '../storage/lessons.js';
|
|
9
6
|
function getIndexPath(cwd) {
|
|
10
7
|
return path.join(cwd, '.rigour', 'patterns.json');
|
|
11
8
|
}
|
|
12
9
|
const DEFAULT_STALE_MS = 24 * 60 * 60 * 1000;
|
|
13
|
-
async function resolveGitBranch(cwd) {
|
|
14
|
-
try {
|
|
15
|
-
let gitDir = path.join(cwd, '.git');
|
|
16
|
-
const gitStat = await fs.stat(gitDir);
|
|
17
|
-
if (gitStat.isFile()) {
|
|
18
|
-
const pointer = await fs.readFile(gitDir, 'utf8');
|
|
19
|
-
const match = pointer.match(/^gitdir:\s*(.+)$/m);
|
|
20
|
-
if (!match)
|
|
21
|
-
return 'workspace';
|
|
22
|
-
gitDir = path.resolve(cwd, match[1].trim());
|
|
23
|
-
}
|
|
24
|
-
const head = (await fs.readFile(path.join(gitDir, 'HEAD'), 'utf8')).trim();
|
|
25
|
-
return head.startsWith('ref: refs/heads/') ? head.slice('ref: refs/heads/'.length) : head.slice(0, 12);
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
return 'workspace';
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
function groupByComponent(patterns) {
|
|
32
|
-
const groups = new Map();
|
|
33
|
-
for (const p of patterns) {
|
|
34
|
-
const key = p.type === 'component' ? p.name : p.category || 'uncategorized';
|
|
35
|
-
const list = groups.get(key) ?? [];
|
|
36
|
-
list.push(p);
|
|
37
|
-
groups.set(key, list);
|
|
38
|
-
}
|
|
39
|
-
return groups;
|
|
40
|
-
}
|
|
41
|
-
function buildDossier(component, patterns) {
|
|
42
|
-
const canonicalFiles = [...new Set(patterns.map(p => p.file))];
|
|
43
|
-
const contracts = patterns
|
|
44
|
-
.filter(p => p.signature)
|
|
45
|
-
.map(p => p.signature)
|
|
46
|
-
.slice(0, 20);
|
|
47
|
-
const descriptions = patterns.map(p => p.description).filter(Boolean);
|
|
48
|
-
const responsibility = descriptions[0] || `Indexed ${patterns.length} patterns for ${component}`;
|
|
49
|
-
return {
|
|
50
|
-
component,
|
|
51
|
-
responsibility,
|
|
52
|
-
canonicalFiles,
|
|
53
|
-
contracts,
|
|
54
|
-
directConsumers: [],
|
|
55
|
-
validationCommands: [],
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
/**
|
|
59
|
-
* Sync pattern index entries into static cache and component dossier layers.
|
|
60
|
-
*/
|
|
61
|
-
export async function syncIndexToContextCache(cwd, index) {
|
|
62
|
-
const resolved = path.resolve(cwd);
|
|
63
|
-
const repo = await getRepositoryId(resolved);
|
|
64
|
-
const branch = await resolveGitBranch(resolved);
|
|
65
|
-
const commitSha = hashContent(index.lastUpdated);
|
|
66
|
-
let synced = 0;
|
|
67
|
-
for (const file of index.files) {
|
|
68
|
-
const filePatterns = index.patterns.filter(p => p.file === file.path);
|
|
69
|
-
if (filePatterns.length === 0)
|
|
70
|
-
continue;
|
|
71
|
-
const payload = {
|
|
72
|
-
rigourPatterns: filePatterns.map(p => `${p.type}:${p.name}`),
|
|
73
|
-
exports: filePatterns.filter(p => p.exported).map(p => p.name),
|
|
74
|
-
};
|
|
75
|
-
const fileContent = JSON.stringify(payload);
|
|
76
|
-
await setStaticCache(repo, branch, file.path, fileContent, payload, resolved);
|
|
77
|
-
synced++;
|
|
78
|
-
}
|
|
79
|
-
const componentGroups = groupByComponent(index.patterns);
|
|
80
|
-
for (const [component, patterns] of componentGroups) {
|
|
81
|
-
const dossier = buildDossier(component, patterns);
|
|
82
|
-
const fingerprint = hashContent(JSON.stringify(patterns.map(p => p.id)));
|
|
83
|
-
await setComponentCache(component, commitSha, dossier, fingerprint, '3', resolved);
|
|
84
|
-
synced++;
|
|
85
|
-
}
|
|
86
|
-
return synced;
|
|
87
|
-
}
|
|
88
10
|
/**
|
|
89
11
|
* Check patterns.json freshness relative to the project directory.
|
|
90
12
|
*/
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { ExecutionReceipt } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Another append holds the lock. On Windows, creating a file another process is still deleting
|
|
4
|
+
* fails with EPERM (or EACCES/EBUSY) rather than EEXIST; elsewhere those are real permission errors.
|
|
5
|
+
*/
|
|
6
|
+
export declare function isLockContention(code: unknown, platform?: NodeJS.Platform): boolean;
|
|
2
7
|
export declare function appendExecutionReceipt(cwd: string, input: Omit<ExecutionReceipt, 'version' | 'id' | 'repositoryId' | 'createdAt' | 'previousReceipt' | 'digest' | 'signature'>, controlRoot?: string): Promise<ExecutionReceipt>;
|
|
3
8
|
export declare function listExecutionReceipts(cwd: string, limit?: number, controlRoot?: string): Promise<ExecutionReceipt[]>;
|
|
4
9
|
export declare function verifyExecutionReceiptChain(cwd: string, controlRoot?: string): Promise<{
|
|
@@ -17,6 +17,15 @@ async function receiptKey(cwd, controlRoot) {
|
|
|
17
17
|
await fs.writeFile(keyPath, key, { mode: 0o600 });
|
|
18
18
|
return key;
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Another append holds the lock. On Windows, creating a file another process is still deleting
|
|
22
|
+
* fails with EPERM (or EACCES/EBUSY) rather than EEXIST; elsewhere those are real permission errors.
|
|
23
|
+
*/
|
|
24
|
+
export function isLockContention(code, platform = process.platform) {
|
|
25
|
+
if (code === 'EEXIST')
|
|
26
|
+
return true;
|
|
27
|
+
return platform === 'win32' && (code === 'EPERM' || code === 'EACCES' || code === 'EBUSY');
|
|
28
|
+
}
|
|
20
29
|
async function acquireReceiptLock(logPath) {
|
|
21
30
|
const lockPath = `${logPath}.append-lock`;
|
|
22
31
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
@@ -24,7 +33,7 @@ async function acquireReceiptLock(logPath) {
|
|
|
24
33
|
return await fs.promises.open(lockPath, 'wx', 0o600);
|
|
25
34
|
}
|
|
26
35
|
catch (error) {
|
|
27
|
-
if (error?.code
|
|
36
|
+
if (!isLockContention(error?.code))
|
|
28
37
|
throw error;
|
|
29
38
|
await delay(10);
|
|
30
39
|
}
|
|
@@ -176,3 +176,13 @@ describe('action normalization and receipts', () => {
|
|
|
176
176
|
expect(await verifyExecutionReceiptChain(cwd, root)).toMatchObject({ valid: true, count: 1 });
|
|
177
177
|
});
|
|
178
178
|
});
|
|
179
|
+
describe('receipt lock contention', () => {
|
|
180
|
+
it('retries while another append releases the lock, including Windows delete-pending errors', async () => {
|
|
181
|
+
const { isLockContention } = await import('./execution-receipt.js');
|
|
182
|
+
expect(isLockContention('EEXIST', 'linux')).toBe(true);
|
|
183
|
+
expect(isLockContention('EPERM', 'win32')).toBe(true);
|
|
184
|
+
expect(isLockContention('EBUSY', 'win32')).toBe(true);
|
|
185
|
+
expect(isLockContention('EPERM', 'linux')).toBe(false); // a real permission error elsewhere
|
|
186
|
+
expect(isLockContention('ENOENT', 'win32')).toBe(false);
|
|
187
|
+
});
|
|
188
|
+
});
|
package/dist/hooks/checker.js
CHANGED
|
@@ -249,6 +249,24 @@ function checkSecurityPatterns(lines, relPath, failures) {
|
|
|
249
249
|
checkHardcodedSecrets(line, i, relPath, isTestFile, failures);
|
|
250
250
|
checkCommandInjection(line, i, relPath, failures);
|
|
251
251
|
}
|
|
252
|
+
if (!isTestFile)
|
|
253
|
+
checkVendorKeys(lines.join('\n'), relPath, failures);
|
|
254
|
+
}
|
|
255
|
+
/** Credential formats a vendor issues: unambiguous in code, unlike entropy or variable-name guesses. */
|
|
256
|
+
const VENDOR_KEYS = new Set(['aws_access_key', 'openai_key', 'anthropic_key', 'github_token', 'stripe_key', 'slack_token', 'sendgrid_key', 'private_key', 'private_key_full']);
|
|
257
|
+
/** A real vendor key written into code, whatever the variable is called. */
|
|
258
|
+
function checkVendorKeys(content, relPath, failures) {
|
|
259
|
+
for (const detection of scanInputForCredentials(content).detections) {
|
|
260
|
+
if (!VENDOR_KEYS.has(detection.type) || !detection.position)
|
|
261
|
+
continue;
|
|
262
|
+
const raw = content.slice(detection.position.start, detection.position.end);
|
|
263
|
+
if (/^pk_|_test_/.test(raw))
|
|
264
|
+
continue; // publishable and test keys are meant to be shared
|
|
265
|
+
const line = content.slice(0, detection.position.start).split('\n').length;
|
|
266
|
+
if (failures.some(f => f.gate === 'security-patterns' && f.line === line))
|
|
267
|
+
continue;
|
|
268
|
+
failures.push({ gate: 'security-patterns', file: relPath, message: `${detection.description} in code`, severity: 'critical', line });
|
|
269
|
+
}
|
|
252
270
|
}
|
|
253
271
|
function checkHardcodedSecrets(line, i, relPath, isTestFile, failures) {
|
|
254
272
|
if (isTestFile) {
|
|
@@ -51,6 +51,16 @@ describe('runHookChecker', () => {
|
|
|
51
51
|
expect(result.status).toBe('fail');
|
|
52
52
|
expect(result.failures[0]).toMatchObject({ gate: 'file-guard', severity: 'critical' });
|
|
53
53
|
});
|
|
54
|
+
it('catches a real vendor key whatever it is named, and leaves publishable and test keys alone', async () => {
|
|
55
|
+
// Built at runtime: a key-shaped literal in this source would be (rightly) refused by secret scanning.
|
|
56
|
+
const fake = (prefix) => `${prefix}_${'51HxQ'}${'abcdefghijklmnopqrstuv'}`;
|
|
57
|
+
fs.writeFileSync(path.join(testDir, 'pay.ts'), `export const stripe = {\n value: "${fake('sk_live')}",\n};\n`);
|
|
58
|
+
fs.writeFileSync(path.join(testDir, 'public.ts'), `export const key = "${fake('pk_live')}";\nexport const t = "${fake('sk_test')}";\n`);
|
|
59
|
+
const leaked = await runHookChecker({ cwd: testDir, files: ['pay.ts'] });
|
|
60
|
+
expect(leaked.failures).toEqual([expect.objectContaining({ gate: 'security-patterns', line: 2, severity: 'critical', message: 'Stripe API key detected in code' })]);
|
|
61
|
+
const fine = await runHookChecker({ cwd: testDir, files: ['public.ts'] });
|
|
62
|
+
expect(fine.failures.filter(f => f.gate === 'security-patterns')).toEqual([]);
|
|
63
|
+
});
|
|
54
64
|
it('should detect file size violations', async () => {
|
|
55
65
|
const filePath = path.join(testDir, 'big.ts');
|
|
56
66
|
const lines = Array.from({ length: 100 }, (_, i) => `export const v${i} = ${i};`);
|
package/dist/index.d.ts
CHANGED
|
@@ -29,8 +29,10 @@ export { readLessons, writeLessons, promoteLesson, matchLessons, lessonText, les
|
|
|
29
29
|
export { lessonsForDiff, lessonsSection, type LessonMode } from './review-learning/team-lessons.js';
|
|
30
30
|
export { readRepoRules, rulesForDiff, rulesSection, splitRules, type RepoRule } from './review-learning/repo-rules.js';
|
|
31
31
|
export { diffFromGit, GitDiffError, type DiffSource } from './review/git-diff.js';
|
|
32
|
-
export { checkPrecisions, checkId, precisionOf, isMuted, MUTE_MIN_OUTCOMES, MUTE_BELOW, type CheckPrecision, type CheckOutcome } from './review/check-outcomes.js';
|
|
33
|
-
export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, type ResolvedFix, type FixCapture } from './review/agent-fixes.js';
|
|
32
|
+
export { checkPrecisions, checkId, precisionOf, isMuted, readOutcomes, MUTE_MIN_OUTCOMES, MUTE_BELOW, type CheckPrecision, type CheckOutcome } from './review/check-outcomes.js';
|
|
33
|
+
export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, listOpenFindings, type ResolvedFix, type FixCapture } from './review/agent-fixes.js';
|
|
34
|
+
export { appendStory, readStories, compactDiff, STORIES_FILE, type Story, type CatchStage } from './review/stories.js';
|
|
35
|
+
export { recordLessonsServed, recordPrCatches, recordReuseSuggested, type LessonChannel } from './review/learning-events.js';
|
|
34
36
|
export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys, type AgentEvent, type ReviewEffectiveness } from './review/effectiveness.js';
|
|
35
37
|
export { parseDiff, changedLinesByFile } from './utils/diff.js';
|
|
36
38
|
export { FileScanner } from './utils/scanner.js';
|
|
@@ -46,6 +48,7 @@ export { isModelCached, getModelsDir, getModelInfo, getCachedModel, ensureModel
|
|
|
46
48
|
export { installLlamaEngine, probeBinary, managedEnginePath, LLAMA_RELEASE_TAG } from './inference/llama-engine.js';
|
|
47
49
|
export { SidecarProvider } from './inference/sidecar-provider.js';
|
|
48
50
|
export { extractFacts, factsToPromptString } from './deep/fact-extractor.js';
|
|
51
|
+
export { cleanContextCache, deadCacheRows, type CacheCleanupReport } from './storage/index.js';
|
|
49
52
|
export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, insertScan, insertFindings, getRecentScans, getScoreTrendFromDB, getTopIssues, reinforcePattern, getStrongPatterns } from './storage/index.js';
|
|
50
53
|
export type { RigourDB, CompactResult } from './storage/index.js';
|
|
51
54
|
export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './storage/index.js';
|
package/dist/index.js
CHANGED
|
@@ -29,8 +29,10 @@ export { readLessons, writeLessons, promoteLesson, matchLessons, lessonText, les
|
|
|
29
29
|
export { lessonsForDiff, lessonsSection } from './review-learning/team-lessons.js';
|
|
30
30
|
export { readRepoRules, rulesForDiff, rulesSection, splitRules } from './review-learning/repo-rules.js';
|
|
31
31
|
export { diffFromGit, GitDiffError } from './review/git-diff.js';
|
|
32
|
-
export { checkPrecisions, checkId, precisionOf, isMuted, MUTE_MIN_OUTCOMES, MUTE_BELOW } from './review/check-outcomes.js';
|
|
33
|
-
export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount } from './review/agent-fixes.js';
|
|
32
|
+
export { checkPrecisions, checkId, precisionOf, isMuted, readOutcomes, MUTE_MIN_OUTCOMES, MUTE_BELOW } from './review/check-outcomes.js';
|
|
33
|
+
export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, listOpenFindings } from './review/agent-fixes.js';
|
|
34
|
+
export { appendStory, readStories, compactDiff, STORIES_FILE } from './review/stories.js';
|
|
35
|
+
export { recordLessonsServed, recordPrCatches, recordReuseSuggested } from './review/learning-events.js';
|
|
34
36
|
export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys } from './review/effectiveness.js';
|
|
35
37
|
export { parseDiff, changedLinesByFile } from './utils/diff.js';
|
|
36
38
|
export { FileScanner } from './utils/scanner.js';
|
|
@@ -47,6 +49,7 @@ export { installLlamaEngine, probeBinary, managedEnginePath, LLAMA_RELEASE_TAG }
|
|
|
47
49
|
export { SidecarProvider } from './inference/sidecar-provider.js';
|
|
48
50
|
export { extractFacts, factsToPromptString } from './deep/fact-extractor.js';
|
|
49
51
|
// Storage (SQLite Brain)
|
|
52
|
+
export { cleanContextCache, deadCacheRows } from './storage/index.js';
|
|
50
53
|
export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, insertScan, insertFindings, getRecentScans, getScoreTrendFromDB, getTopIssues, reinforcePattern, getStrongPatterns } from './storage/index.js';
|
|
51
54
|
// Local Project Memory (hybrid intelligence — SQLite-backed per-project learning)
|
|
52
55
|
export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './storage/index.js';
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import type { Failure } from '../types/index.js';
|
|
2
|
+
import { type CatchStage } from './stories.js';
|
|
3
|
+
interface OpenFinding {
|
|
4
|
+
file: string;
|
|
5
|
+
rule: string;
|
|
6
|
+
/** What the finding said; absent in entries captured before it was kept. */
|
|
7
|
+
title?: string;
|
|
8
|
+
details?: string;
|
|
9
|
+
before: string;
|
|
10
|
+
openedAt: string;
|
|
11
|
+
/** The stage that first reported it; absent in entries captured before stages were kept. */
|
|
12
|
+
stage?: CatchStage;
|
|
13
|
+
/** Its dismissal key (quiet.ts): a finding someone dismissed was not fixed. */
|
|
14
|
+
key?: string;
|
|
15
|
+
}
|
|
2
16
|
export interface ResolvedFix {
|
|
3
17
|
id: string;
|
|
4
18
|
file: string;
|
|
@@ -8,6 +22,9 @@ export interface ResolvedFix {
|
|
|
8
22
|
before: string;
|
|
9
23
|
after: string;
|
|
10
24
|
resolvedAt: string;
|
|
25
|
+
stage?: CatchStage;
|
|
26
|
+
/** When the finding was first reported, for time to fix. */
|
|
27
|
+
openedAt?: string;
|
|
11
28
|
}
|
|
12
29
|
export interface FixCapture {
|
|
13
30
|
opened: number;
|
|
@@ -15,8 +32,14 @@ export interface FixCapture {
|
|
|
15
32
|
/** The fixes this review resolved, for turning into lessons (storage/fix-lessons.ts). */
|
|
16
33
|
fixes: ResolvedFix[];
|
|
17
34
|
}
|
|
18
|
-
/**
|
|
19
|
-
|
|
35
|
+
/**
|
|
36
|
+
* Record a review's findings: open new ones, resolve open ones the review no longer reports.
|
|
37
|
+
* Each resolved fix is also kept as a story, credited to the stage that first reported it.
|
|
38
|
+
*/
|
|
39
|
+
export declare function recordReviewOutcome(cwd: string, findings: Failure[], reviewedFiles: string[], stage?: CatchStage): FixCapture;
|
|
20
40
|
export declare function listResolvedFixes(cwd: string): ResolvedFix[];
|
|
21
41
|
export declare function removeResolvedFix(cwd: string, id: string): void;
|
|
22
42
|
export declare function openFindingCount(cwd: string): number;
|
|
43
|
+
/** Findings reported and not fixed yet, newest first: what still needs someone. */
|
|
44
|
+
export declare function listOpenFindings(cwd: string): Array<Omit<OpenFinding, 'before'>>;
|
|
45
|
+
export {};
|
|
@@ -14,24 +14,37 @@ import crypto from 'crypto';
|
|
|
14
14
|
import fs from 'fs';
|
|
15
15
|
import path from 'path';
|
|
16
16
|
import { checkId, recordOutcome } from './check-outcomes.js';
|
|
17
|
+
import { appendStory, compactDiff } from './stories.js';
|
|
18
|
+
import { dismissedKeys, findingKey } from './quiet.js';
|
|
17
19
|
const DIR = path.join('.rigour', 'agent-fixes');
|
|
18
20
|
const MAX_FILE_BYTES = 200_000;
|
|
19
21
|
const OPEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
20
|
-
/**
|
|
21
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Record a review's findings: open new ones, resolve open ones the review no longer reports.
|
|
24
|
+
* Each resolved fix is also kept as a story, credited to the stage that first reported it.
|
|
25
|
+
*/
|
|
26
|
+
export function recordReviewOutcome(cwd, findings, reviewedFiles, stage = 'review') {
|
|
22
27
|
const open = readOpen(cwd);
|
|
23
28
|
const current = new Map(findings.flatMap(f => (f.files?.[0] ? [[`${f.id}:${f.files[0]}`, f]] : [])));
|
|
24
29
|
const fixes = resolveGone(cwd, open, current, new Set(reviewedFiles));
|
|
25
|
-
const
|
|
30
|
+
for (const fix of fixes) {
|
|
31
|
+
appendStory(cwd, { at: fix.resolvedAt, openedAt: fix.openedAt, stage: fix.stage ?? stage, file: fix.file, rule: fix.rule, title: fix.title ?? fix.rule, details: fix.details, diff: compactDiff(fix.before, fix.after) });
|
|
32
|
+
}
|
|
33
|
+
const opened = openNew(cwd, open, current, stage);
|
|
26
34
|
writeOpen(cwd, open);
|
|
27
35
|
return { opened, resolved: fixes.length, fixes };
|
|
28
36
|
}
|
|
29
37
|
/** Open findings this review no longer reports: resolved when their file was reviewed and changed. */
|
|
30
38
|
function resolveGone(cwd, open, current, reviewed) {
|
|
31
39
|
const resolved = [];
|
|
40
|
+
const dismissed = dismissedKeys(cwd);
|
|
32
41
|
for (const [key, entry] of Object.entries(open)) {
|
|
33
42
|
if (current.has(key))
|
|
34
43
|
continue;
|
|
44
|
+
if (entry.key && dismissed.has(entry.key)) {
|
|
45
|
+
delete open[key]; // gone because a person dismissed it, not because anyone fixed it
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
35
48
|
if (!reviewed.has(entry.file)) {
|
|
36
49
|
if (Date.now() - Date.parse(entry.openedAt) > OPEN_TTL_MS)
|
|
37
50
|
delete open[key];
|
|
@@ -46,7 +59,7 @@ function resolveGone(cwd, open, current, reviewed) {
|
|
|
46
59
|
}
|
|
47
60
|
return resolved;
|
|
48
61
|
}
|
|
49
|
-
function openNew(cwd, open, current) {
|
|
62
|
+
function openNew(cwd, open, current, stage) {
|
|
50
63
|
let opened = 0;
|
|
51
64
|
for (const [key, finding] of current) {
|
|
52
65
|
if (open[key])
|
|
@@ -55,7 +68,7 @@ function openNew(cwd, open, current) {
|
|
|
55
68
|
const before = readSmall(cwd, file);
|
|
56
69
|
if (before === null)
|
|
57
70
|
continue;
|
|
58
|
-
open[key] = { file, rule: finding.id, title: finding.title, details: finding.details, before, openedAt: new Date().toISOString() };
|
|
71
|
+
open[key] = { file, rule: finding.id, title: finding.title, details: finding.details, before, openedAt: new Date().toISOString(), stage, key: findingKey(finding) };
|
|
59
72
|
opened++;
|
|
60
73
|
}
|
|
61
74
|
return opened;
|
|
@@ -79,6 +92,14 @@ export function removeResolvedFix(cwd, id) {
|
|
|
79
92
|
export function openFindingCount(cwd) {
|
|
80
93
|
return Object.keys(readOpen(cwd)).length;
|
|
81
94
|
}
|
|
95
|
+
/** Findings reported and not fixed yet, newest first: what still needs someone. */
|
|
96
|
+
export function listOpenFindings(cwd) {
|
|
97
|
+
const dismissed = dismissedKeys(cwd);
|
|
98
|
+
return Object.values(readOpen(cwd))
|
|
99
|
+
.filter(entry => !(entry.key && dismissed.has(entry.key)))
|
|
100
|
+
.map(({ before: _before, ...rest }) => rest)
|
|
101
|
+
.sort((a, b) => b.openedAt.localeCompare(a.openedAt));
|
|
102
|
+
}
|
|
82
103
|
function readOpen(cwd) {
|
|
83
104
|
try {
|
|
84
105
|
return JSON.parse(fs.readFileSync(path.join(cwd, DIR, 'open.json'), 'utf8'));
|
|
@@ -98,7 +119,7 @@ function writeOpen(cwd, open) {
|
|
|
98
119
|
}
|
|
99
120
|
function writeResolved(cwd, entry, after) {
|
|
100
121
|
const id = crypto.createHash('sha256').update(`${entry.rule}\0${entry.file}\0${entry.before}\0${after}`).digest('hex').slice(0, 16);
|
|
101
|
-
const fix = { id, file: entry.file, rule: entry.rule, title: entry.title, details: entry.details, before: entry.before, after, resolvedAt: new Date().toISOString() };
|
|
122
|
+
const fix = { id, file: entry.file, rule: entry.rule, title: entry.title, details: entry.details, before: entry.before, after, resolvedAt: new Date().toISOString(), ...(entry.stage ? { stage: entry.stage } : {}), openedAt: entry.openedAt };
|
|
102
123
|
try {
|
|
103
124
|
fs.mkdirSync(path.join(cwd, DIR, 'resolved'), { recursive: true });
|
|
104
125
|
fs.writeFileSync(path.join(cwd, DIR, 'resolved', `${id}.json`), JSON.stringify(fix));
|
|
@@ -11,6 +11,19 @@ export interface AgentEvent {
|
|
|
11
11
|
blocked?: boolean;
|
|
12
12
|
/** Findings that blocked a stop. */
|
|
13
13
|
blocking?: number;
|
|
14
|
+
/** lessons_served: where the agent got them, and their subjects (learning-events.ts). */
|
|
15
|
+
via?: string;
|
|
16
|
+
lessons?: string[];
|
|
17
|
+
/** pr_catches: what a branch review reported. */
|
|
18
|
+
findings?: Array<{
|
|
19
|
+
rule: string;
|
|
20
|
+
title: string;
|
|
21
|
+
file: string;
|
|
22
|
+
}>;
|
|
23
|
+
/** reuse_suggested: what the agent was about to write, what already exists, and BLOCK or WARN. */
|
|
24
|
+
planned?: string;
|
|
25
|
+
existing?: string;
|
|
26
|
+
action?: string;
|
|
14
27
|
}
|
|
15
28
|
export interface ReviewEffectiveness {
|
|
16
29
|
toolCalls: Record<string, number>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two events the learning loop is measured by, written to the same local event log as every
|
|
3
|
+
* agent event: a lesson put in front of an agent, and findings a PR-scope review reported.
|
|
4
|
+
* Studio joins them with stories (stories.ts) to show each lesson's path from where it was
|
|
5
|
+
* learned to whether the same mistake still reaches a PR.
|
|
6
|
+
*/
|
|
7
|
+
import type { Failure } from '../types/index.js';
|
|
8
|
+
export type LessonChannel = 'recall' | 'context' | 'review';
|
|
9
|
+
/** Lessons an agent was given before or while writing code. Nothing is written when none were. */
|
|
10
|
+
export declare function recordLessonsServed(cwd: string, via: LessonChannel, subjects: string[]): void;
|
|
11
|
+
/** Findings a review of a whole branch (what a PR contains) reported. Nothing is written when clean. */
|
|
12
|
+
export declare function recordPrCatches(cwd: string, findings: Failure[]): void;
|
|
13
|
+
/** The agent was about to write something that already exists, and was pointed to it. */
|
|
14
|
+
export declare function recordReuseSuggested(cwd: string, planned: string, existing: string, action: 'BLOCK' | 'WARN'): void;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { appendAgentEvent } from './effectiveness.js';
|
|
2
|
+
const MAX_LISTED = 20;
|
|
3
|
+
/** Lessons an agent was given before or while writing code. Nothing is written when none were. */
|
|
4
|
+
export function recordLessonsServed(cwd, via, subjects) {
|
|
5
|
+
const unique = [...new Set(subjects.filter(Boolean))].slice(0, MAX_LISTED);
|
|
6
|
+
if (unique.length)
|
|
7
|
+
appendAgentEvent(cwd, { type: 'lessons_served', via, lessons: unique });
|
|
8
|
+
}
|
|
9
|
+
/** Findings a review of a whole branch (what a PR contains) reported. Nothing is written when clean. */
|
|
10
|
+
export function recordPrCatches(cwd, findings) {
|
|
11
|
+
if (findings.length === 0)
|
|
12
|
+
return;
|
|
13
|
+
appendAgentEvent(cwd, {
|
|
14
|
+
type: 'pr_catches',
|
|
15
|
+
findings: findings.slice(0, MAX_LISTED).map(f => ({ rule: f.id, title: f.title, file: f.files?.[0] ?? '' })),
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
/** The agent was about to write something that already exists, and was pointed to it. */
|
|
19
|
+
export function recordReuseSuggested(cwd, planned, existing, action) {
|
|
20
|
+
appendAgentEvent(cwd, { type: 'reuse_suggested', planned, existing, action });
|
|
21
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Where a problem was caught: while the agent wrote, when it asked to review, when it tried to finish, at the PR. */
|
|
2
|
+
export type CatchStage = 'edit' | 'review' | 'stop' | 'pr';
|
|
3
|
+
export interface Story {
|
|
4
|
+
id: string;
|
|
5
|
+
/** When the agent fixed it. */
|
|
6
|
+
at: string;
|
|
7
|
+
/** When Rigour first reported it; absent in stories kept before this was recorded. */
|
|
8
|
+
openedAt?: string;
|
|
9
|
+
stage: CatchStage;
|
|
10
|
+
file: string;
|
|
11
|
+
rule: string;
|
|
12
|
+
title: string;
|
|
13
|
+
details?: string;
|
|
14
|
+
/** Changed lines with context, each prefixed ' ', '-' or '+'. */
|
|
15
|
+
diff: string[];
|
|
16
|
+
}
|
|
17
|
+
export declare const STORIES_FILE: string;
|
|
18
|
+
export declare function appendStory(cwd: string, story: Omit<Story, 'id'>): void;
|
|
19
|
+
/** Every story, oldest first; `since` keeps those at or after it. */
|
|
20
|
+
export declare function readStories(cwd: string, since?: Date): Story[];
|
|
21
|
+
/**
|
|
22
|
+
* The lines that changed between two versions of a file, with context: the common head and
|
|
23
|
+
* tail are trimmed and what is left between them is shown as removed then added.
|
|
24
|
+
*/
|
|
25
|
+
export declare function compactDiff(before: string, after: string): string[];
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What Rigour stopped, as people read it: one entry per problem an agent fixed after Rigour
|
|
3
|
+
* reported it, with the stage that caught it and the code change that fixed it.
|
|
4
|
+
*
|
|
5
|
+
* agent-fixes.ts keeps the before and after only until `rigour learn` turns them into rules;
|
|
6
|
+
* this log keeps a small, permanent summary for Studio. It never holds whole files: only the
|
|
7
|
+
* changed lines with a little context, capped.
|
|
8
|
+
*/
|
|
9
|
+
import crypto from 'crypto';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
export const STORIES_FILE = path.join('.rigour', 'stories.jsonl');
|
|
13
|
+
/** Kept on disk; older entries are dropped when the log is rewritten. */
|
|
14
|
+
const MAX_STORIES = 500;
|
|
15
|
+
const CONTEXT_LINES = 2;
|
|
16
|
+
const MAX_DIFF_LINES = 40;
|
|
17
|
+
const MAX_LINE_CHARS = 200;
|
|
18
|
+
export function appendStory(cwd, story) {
|
|
19
|
+
const entry = { id: crypto.createHash('sha256').update(`${story.at}\0${story.file}\0${story.rule}`).digest('hex').slice(0, 16), ...story };
|
|
20
|
+
const file = path.join(cwd, STORIES_FILE);
|
|
21
|
+
try {
|
|
22
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
23
|
+
fs.appendFileSync(file, JSON.stringify(entry) + '\n');
|
|
24
|
+
trim(file);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Stories are a view; failing to keep one must never break a review or a hook.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Every story, oldest first; `since` keeps those at or after it. */
|
|
31
|
+
export function readStories(cwd, since) {
|
|
32
|
+
let lines;
|
|
33
|
+
try {
|
|
34
|
+
lines = fs.readFileSync(path.join(cwd, STORIES_FILE), 'utf8').split('\n');
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
const stories = lines.flatMap(line => {
|
|
40
|
+
try {
|
|
41
|
+
return line.trim() ? [JSON.parse(line)] : [];
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
return since ? stories.filter(s => Date.parse(s.at) >= since.getTime()) : stories;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The lines that changed between two versions of a file, with context: the common head and
|
|
51
|
+
* tail are trimmed and what is left between them is shown as removed then added.
|
|
52
|
+
*/
|
|
53
|
+
export function compactDiff(before, after) {
|
|
54
|
+
const a = before.split('\n');
|
|
55
|
+
const b = after.split('\n');
|
|
56
|
+
let head = 0;
|
|
57
|
+
while (head < a.length && head < b.length && a[head] === b[head])
|
|
58
|
+
head++;
|
|
59
|
+
let tail = 0;
|
|
60
|
+
while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail])
|
|
61
|
+
tail++;
|
|
62
|
+
const clip = (line) => (line.length > MAX_LINE_CHARS ? line.slice(0, MAX_LINE_CHARS) + '…' : line);
|
|
63
|
+
const lines = [
|
|
64
|
+
...a.slice(Math.max(0, head - CONTEXT_LINES), head).map(l => ' ' + clip(l)),
|
|
65
|
+
...a.slice(head, a.length - tail).map(l => '-' + clip(l)),
|
|
66
|
+
...b.slice(head, b.length - tail).map(l => '+' + clip(l)),
|
|
67
|
+
...a.slice(a.length - tail, Math.min(a.length, a.length - tail + CONTEXT_LINES)).map(l => ' ' + clip(l)),
|
|
68
|
+
];
|
|
69
|
+
return lines.length > MAX_DIFF_LINES ? [...lines.slice(0, MAX_DIFF_LINES), ' …'] : lines;
|
|
70
|
+
}
|
|
71
|
+
function trim(file) {
|
|
72
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter(Boolean);
|
|
73
|
+
if (lines.length > MAX_STORIES * 1.2)
|
|
74
|
+
fs.writeFileSync(file, lines.slice(-MAX_STORIES).join('\n') + '\n');
|
|
75
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,58 @@
|
|
|
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 { recordReviewOutcome } from './agent-fixes.js';
|
|
6
|
+
import { readAgentEvents } from './effectiveness.js';
|
|
7
|
+
import { recordLessonsServed, recordPrCatches } from './learning-events.js';
|
|
8
|
+
import { compactDiff, readStories } from './stories.js';
|
|
9
|
+
let cwd;
|
|
10
|
+
beforeEach(() => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'stories-')); fs.mkdirSync(path.join(cwd, 'src')); });
|
|
11
|
+
afterEach(() => fs.rmSync(cwd, { recursive: true, force: true }));
|
|
12
|
+
const finding = (id, title) => ({ id, title, details: `${title}.`, severity: 'high', files: ['src/a.ts'], line: 2 });
|
|
13
|
+
describe('compactDiff', () => {
|
|
14
|
+
it('keeps only the changed lines and two lines of context', () => {
|
|
15
|
+
const before = ['a', 'b', 'c', 'old', 'd', 'e', 'f'].join('\n');
|
|
16
|
+
const after = ['a', 'b', 'c', 'new', 'd', 'e', 'f'].join('\n');
|
|
17
|
+
expect(compactDiff(before, after)).toEqual([' b', ' c', '-old', '+new', ' d', ' e']);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe('stories from fixes', () => {
|
|
21
|
+
it('credits a fix to the stage that first reported it, with the code change', () => {
|
|
22
|
+
fs.writeFileSync(path.join(cwd, 'src/a.ts'), "fetch(url, { headers });\n");
|
|
23
|
+
recordReviewOutcome(cwd, [finding('semantic-bugs', 'Credential header follows redirects')], ['src/a.ts'], 'edit');
|
|
24
|
+
fs.writeFileSync(path.join(cwd, 'src/a.ts'), "fetch(url, { headers, redirect: 'error' });\n");
|
|
25
|
+
recordReviewOutcome(cwd, [], ['src/a.ts'], 'stop');
|
|
26
|
+
const [story] = readStories(cwd);
|
|
27
|
+
expect(story).toMatchObject({ stage: 'edit', file: 'src/a.ts', rule: 'semantic-bugs', title: 'Credential header follows redirects' });
|
|
28
|
+
expect(story.diff).toEqual(['-fetch(url, { headers });', "+fetch(url, { headers, redirect: 'error' });", ' ']);
|
|
29
|
+
});
|
|
30
|
+
it('writes no story for a finding someone dismissed, even after its file changed', async () => {
|
|
31
|
+
const { dismissFinding, findingKey } = await import('./quiet.js');
|
|
32
|
+
const f = finding('semantic-bugs', 'Looks risky');
|
|
33
|
+
fs.writeFileSync(path.join(cwd, 'src/a.ts'), 'x\n');
|
|
34
|
+
recordReviewOutcome(cwd, [f], ['src/a.ts'], 'review');
|
|
35
|
+
dismissFinding(cwd, findingKey(f), 'intended');
|
|
36
|
+
fs.writeFileSync(path.join(cwd, 'src/a.ts'), 'y\n');
|
|
37
|
+
recordReviewOutcome(cwd, [], ['src/a.ts'], 'review');
|
|
38
|
+
expect(readStories(cwd)).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
it('writes no story while the finding is still open', () => {
|
|
41
|
+
fs.writeFileSync(path.join(cwd, 'src/a.ts'), 'x\n');
|
|
42
|
+
recordReviewOutcome(cwd, [finding('semantic-bugs', 'X')], ['src/a.ts'], 'review');
|
|
43
|
+
expect(readStories(cwd)).toEqual([]);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('learning events', () => {
|
|
47
|
+
it('records lessons served and PR catches, and nothing when there are none', () => {
|
|
48
|
+
recordLessonsServed(cwd, 'recall', []);
|
|
49
|
+
recordPrCatches(cwd, []);
|
|
50
|
+
expect(readAgentEvents(cwd)).toEqual([]);
|
|
51
|
+
recordLessonsServed(cwd, 'review', ['Never follow redirects with credentials', 'Never follow redirects with credentials']);
|
|
52
|
+
recordPrCatches(cwd, [finding('semantic-bugs', 'Unbounded query')]);
|
|
53
|
+
expect(readAgentEvents(cwd)).toEqual([
|
|
54
|
+
expect.objectContaining({ type: 'lessons_served', via: 'review', lessons: ['Never follow redirects with credentials'] }),
|
|
55
|
+
expect.objectContaining({ type: 'pr_catches', findings: [{ rule: 'semantic-bugs', title: 'Unbounded query', file: 'src/a.ts' }] }),
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Layers no reader uses (context/cache-engine.ts keeps only semantic and checkpoint). */
|
|
2
|
+
export declare const DEAD_CACHE_TYPES: readonly ["static", "component"];
|
|
3
|
+
export interface CacheCleanupReport {
|
|
4
|
+
removed: number;
|
|
5
|
+
kept: number;
|
|
6
|
+
sizeBefore: number;
|
|
7
|
+
sizeAfter: number;
|
|
8
|
+
vacuumed: boolean;
|
|
9
|
+
/** Why the file was not shrunk, when it was not. */
|
|
10
|
+
vacuumSkipped?: string;
|
|
11
|
+
}
|
|
12
|
+
/** How many dead rows the database holds, and its size on disk; null when there is no database. */
|
|
13
|
+
export declare function deadCacheRows(dbPath?: string): Promise<{
|
|
14
|
+
rows: number;
|
|
15
|
+
bytes: number;
|
|
16
|
+
} | null>;
|
|
17
|
+
export declare function cleanContextCache(dbPath?: string, freeBytes?: typeof freeSpace): Promise<CacheCleanupReport>;
|
|
18
|
+
declare function freeSpace(dir: string): number;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removing context-cache rows nothing reads: the static and component layers that index sync wrote
|
|
3
|
+
* on every edit until it was removed (millions of rows, gigabytes, in real use).
|
|
4
|
+
*
|
|
5
|
+
* Built for a database that is large and a disk that is nearly full. Deleting the rows one by one
|
|
6
|
+
* would copy every changed page into the write-ahead log (as much again on disk); instead the rows
|
|
7
|
+
* worth keeping are set aside, the table is dropped (its pages are freed, not rewritten) and
|
|
8
|
+
* recreated. VACUUM, which hands the freed space back to the disk, needs room for a copy of what
|
|
9
|
+
* remains, so it runs only when that room exists.
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { DB_PATH, openDatabase } from './db.js';
|
|
14
|
+
/** Layers no reader uses (context/cache-engine.ts keeps only semantic and checkpoint). */
|
|
15
|
+
export const DEAD_CACHE_TYPES = ['static', 'component'];
|
|
16
|
+
const DEAD_LIST = DEAD_CACHE_TYPES.map(t => `'${t}'`).join(', ');
|
|
17
|
+
/** VACUUM writes a copy of the live data; ask for that much free space, with headroom. */
|
|
18
|
+
const VACUUM_HEADROOM = 1.2;
|
|
19
|
+
/** How many dead rows the database holds, and its size on disk; null when there is no database. */
|
|
20
|
+
export async function deadCacheRows(dbPath = DB_PATH) {
|
|
21
|
+
if (!fs.existsSync(dbPath))
|
|
22
|
+
return null;
|
|
23
|
+
const db = await openDatabase(dbPath);
|
|
24
|
+
if (!db)
|
|
25
|
+
return null;
|
|
26
|
+
try {
|
|
27
|
+
const row = await db.get(`SELECT COUNT(*) AS n FROM context_cache WHERE cache_type IN (${DEAD_LIST})`);
|
|
28
|
+
return { rows: Number(row?.n ?? 0), bytes: fileBytes(dbPath) };
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await db.close();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function cleanContextCache(dbPath = DB_PATH, freeBytes = freeSpace) {
|
|
35
|
+
const sizeBefore = fileBytes(dbPath);
|
|
36
|
+
const db = await openDatabase(dbPath);
|
|
37
|
+
if (!db)
|
|
38
|
+
return { removed: 0, kept: 0, sizeBefore, sizeAfter: sizeBefore, vacuumed: false, vacuumSkipped: 'SQLite is not available' };
|
|
39
|
+
try {
|
|
40
|
+
const { removed, kept } = await dropDeadRows(db);
|
|
41
|
+
if (removed === 0)
|
|
42
|
+
return { removed, kept, sizeBefore, sizeAfter: sizeBefore, vacuumed: false, vacuumSkipped: 'nothing to remove' };
|
|
43
|
+
const live = await liveBytes(db);
|
|
44
|
+
const available = freeBytes(path.dirname(dbPath));
|
|
45
|
+
if (available < live * VACUUM_HEADROOM) {
|
|
46
|
+
return { removed, kept, sizeBefore, sizeAfter: fileBytes(dbPath), vacuumed: false,
|
|
47
|
+
vacuumSkipped: `needs about ${mb(live * VACUUM_HEADROOM)} free to shrink the file, ${mb(available)} available; the space is reused by Rigour either way` };
|
|
48
|
+
}
|
|
49
|
+
await db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
50
|
+
await db.exec('VACUUM');
|
|
51
|
+
await db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
52
|
+
return { removed, kept, sizeBefore, sizeAfter: fileBytes(dbPath), vacuumed: true };
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
await db.close();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Set the readable rows aside, drop the table, recreate it with its indexes, put them back. */
|
|
59
|
+
async function dropDeadRows(db) {
|
|
60
|
+
return db.transaction(async (tx) => {
|
|
61
|
+
const removed = Number((await tx.get(`SELECT COUNT(*) AS n FROM context_cache WHERE cache_type IN (${DEAD_LIST})`))?.n ?? 0);
|
|
62
|
+
if (removed === 0)
|
|
63
|
+
return { removed, kept: 0 };
|
|
64
|
+
const schema = await tx.all(`SELECT sql FROM sqlite_master WHERE tbl_name = 'context_cache' AND sql IS NOT NULL ORDER BY type DESC`);
|
|
65
|
+
await tx.exec(`CREATE TEMP TABLE context_cache_keep AS SELECT * FROM context_cache WHERE cache_type NOT IN (${DEAD_LIST})`);
|
|
66
|
+
await tx.exec('DROP TABLE context_cache');
|
|
67
|
+
for (const { sql } of schema)
|
|
68
|
+
await tx.exec(sql); // the table first (type 'table' sorts after 'index' descending), then its indexes
|
|
69
|
+
await tx.exec('INSERT INTO context_cache SELECT * FROM context_cache_keep');
|
|
70
|
+
const kept = Number((await tx.get('SELECT COUNT(*) AS n FROM context_cache'))?.n ?? 0);
|
|
71
|
+
await tx.exec('DROP TABLE context_cache_keep');
|
|
72
|
+
return { removed, kept };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async function liveBytes(db) {
|
|
76
|
+
const pages = Number((await db.get('PRAGMA page_count'))?.page_count ?? 0);
|
|
77
|
+
const free = Number((await db.get('PRAGMA freelist_count'))?.freelist_count ?? 0);
|
|
78
|
+
const size = Number((await db.get('PRAGMA page_size'))?.page_size ?? 4096);
|
|
79
|
+
return (pages - free) * size;
|
|
80
|
+
}
|
|
81
|
+
function fileBytes(dbPath) {
|
|
82
|
+
return [dbPath, `${dbPath}-wal`].reduce((n, f) => n + (fs.existsSync(f) ? fs.statSync(f).size : 0), 0);
|
|
83
|
+
}
|
|
84
|
+
function freeSpace(dir) {
|
|
85
|
+
const stats = fs.statfsSync(dir);
|
|
86
|
+
return Number(stats.bavail) * Number(stats.bsize);
|
|
87
|
+
}
|
|
88
|
+
function mb(bytes) {
|
|
89
|
+
return bytes >= 1024 ** 3 ? `${(bytes / 1024 ** 3).toFixed(1)} GB` : `${Math.round(bytes / 1024 ** 2)} MB`;
|
|
90
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,54 @@
|
|
|
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 { cleanContextCache, deadCacheRows } from './cache-cleanup.js';
|
|
6
|
+
import { openDatabase } from './db.js';
|
|
7
|
+
let dir;
|
|
8
|
+
let dbPath;
|
|
9
|
+
async function seed(rows) {
|
|
10
|
+
const db = (await openDatabase(dbPath));
|
|
11
|
+
// One transaction: row-at-a-time commits are slow enough on Windows to time the test out.
|
|
12
|
+
await db.exec('BEGIN');
|
|
13
|
+
for (const [key, type] of rows) {
|
|
14
|
+
await db.run(`INSERT INTO context_cache (cache_key, cache_type, repo, branch, dependency_fingerprint, payload_json, payload_tokens, created_at)
|
|
15
|
+
VALUES (?, ?, 'r', 'main', 'f', ?, 1, 0)`, key, type, JSON.stringify({ padding: 'x'.repeat(2000) }));
|
|
16
|
+
}
|
|
17
|
+
await db.exec('COMMIT');
|
|
18
|
+
await db.close();
|
|
19
|
+
}
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cache-cleanup-')); // a temporary database: never ~/.rigour
|
|
22
|
+
dbPath = path.join(dir, 'rigour.db');
|
|
23
|
+
});
|
|
24
|
+
// Windows keeps a just-closed SQLite file locked for a moment: retry the removal instead of failing.
|
|
25
|
+
afterEach(() => fs.rmSync(dir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }));
|
|
26
|
+
// Real SQLite files on disk: slower on Windows runners than the 5 s default allows.
|
|
27
|
+
describe('cleanContextCache', { timeout: 30_000 }, () => {
|
|
28
|
+
it('removes the unread layers, keeps the readable rows and the indexes, and shrinks the file', async () => {
|
|
29
|
+
await seed([
|
|
30
|
+
...Array.from({ length: 200 }, (_, i) => [`static:${i}`, 'static']),
|
|
31
|
+
...Array.from({ length: 200 }, (_, i) => [`component:${i}`, 'component']),
|
|
32
|
+
['semantic:a', 'semantic'], ['checkpoint:b', 'checkpoint'],
|
|
33
|
+
]);
|
|
34
|
+
expect(await deadCacheRows(dbPath)).toMatchObject({ rows: 400 });
|
|
35
|
+
const report = await cleanContextCache(dbPath, () => Number.MAX_SAFE_INTEGER);
|
|
36
|
+
expect(report).toMatchObject({ removed: 400, kept: 2, vacuumed: true });
|
|
37
|
+
expect(report.sizeAfter).toBeLessThan(report.sizeBefore);
|
|
38
|
+
const db = (await openDatabase(dbPath));
|
|
39
|
+
expect((await db.all('SELECT cache_key FROM context_cache ORDER BY cache_key')).map(r => r.cache_key)).toEqual(['checkpoint:b', 'semantic:a']);
|
|
40
|
+
expect((await db.all("SELECT name FROM sqlite_master WHERE tbl_name = 'context_cache' AND type = 'index'")).map(r => r.name)).toContain('idx_context_cache_type');
|
|
41
|
+
await db.close();
|
|
42
|
+
expect(await deadCacheRows(dbPath)).toMatchObject({ rows: 0 });
|
|
43
|
+
});
|
|
44
|
+
it('still removes the rows but leaves the file size alone when the disk lacks room to shrink it', async () => {
|
|
45
|
+
await seed([['static:1', 'static'], ['semantic:a', 'semantic']]);
|
|
46
|
+
const report = await cleanContextCache(dbPath, () => 0);
|
|
47
|
+
expect(report).toMatchObject({ removed: 1, kept: 1, vacuumed: false });
|
|
48
|
+
expect(report.vacuumSkipped).toContain('free to shrink the file');
|
|
49
|
+
});
|
|
50
|
+
it('does nothing when there is nothing to remove', async () => {
|
|
51
|
+
await seed([['semantic:a', 'semantic']]);
|
|
52
|
+
expect(await cleanContextCache(dbPath, () => 0)).toMatchObject({ removed: 0, vacuumed: false, vacuumSkipped: 'nothing to remove' });
|
|
53
|
+
});
|
|
54
|
+
});
|
package/dist/storage/db.js
CHANGED
|
@@ -10,6 +10,7 @@ import path from 'path';
|
|
|
10
10
|
import os from 'os';
|
|
11
11
|
import fs from 'fs-extra';
|
|
12
12
|
import { createRequire } from 'module';
|
|
13
|
+
import { rigourUserDir } from '../utils/user-state.js';
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// Optional dynamic import of sqlite3
|
|
15
16
|
// ---------------------------------------------------------------------------
|
|
@@ -42,7 +43,8 @@ function loadSqlite3() {
|
|
|
42
43
|
sqlite3Module = null;
|
|
43
44
|
return sqlite3Module;
|
|
44
45
|
}
|
|
45
|
-
|
|
46
|
+
// RIGOUR_HOME when set (tests, sandboxes) so nothing but a real run touches ~/.rigour.
|
|
47
|
+
const RIGOUR_DIR = rigourUserDir();
|
|
46
48
|
const DB_PATH = path.join(RIGOUR_DIR, 'rigour.db');
|
|
47
49
|
/** Current schema version — bump when adding migrations. */
|
|
48
50
|
const SCHEMA_VERSION = 7;
|
package/dist/storage/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Everything in one file: ~/.rigour/rigour.db
|
|
4
4
|
*/
|
|
5
5
|
export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, RIGOUR_DIR, DB_PATH } from './db.js';
|
|
6
|
+
export { cleanContextCache, deadCacheRows, DEAD_CACHE_TYPES, type CacheCleanupReport } from './cache-cleanup.js';
|
|
6
7
|
export type { RigourDB, CompactResult } from './db.js';
|
|
7
8
|
export { insertScan, getRecentScans, getScoreTrendFromDB, getTopIssues } from './scans.js';
|
|
8
9
|
export { insertFindings, getFindingsForScan, getDeepFindings } from './findings.js';
|
package/dist/storage/index.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Everything in one file: ~/.rigour/rigour.db
|
|
4
4
|
*/
|
|
5
5
|
export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, RIGOUR_DIR, DB_PATH } from './db.js';
|
|
6
|
+
export { cleanContextCache, deadCacheRows, DEAD_CACHE_TYPES } from './cache-cleanup.js';
|
|
6
7
|
export { insertScan, getRecentScans, getScoreTrendFromDB, getTopIssues } from './scans.js';
|
|
7
8
|
export { insertFindings, getFindingsForScan, getDeepFindings } from './findings.js';
|
|
8
9
|
export { reinforcePattern, decayPatterns, getStrongPatterns, getPatterns, getHardRules } from './patterns.js';
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes } from 'crypto';
|
|
2
2
|
import { chmod, link, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
|
-
import os from 'os';
|
|
5
4
|
import path from 'path';
|
|
6
|
-
|
|
5
|
+
import { rigourUserDir } from '../utils/user-state.js';
|
|
6
|
+
const KEY_PATH = path.join(rigourUserDir(), 'team-cache.key');
|
|
7
7
|
let fileKeyPromise;
|
|
8
8
|
function decodeKey(value, source) {
|
|
9
9
|
const normalized = value.trim();
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
|
-
import os from 'os';
|
|
3
2
|
import path from 'path';
|
|
4
3
|
import { openDatabase } from './db.js';
|
|
5
4
|
import { decryptLocalPayload, encryptLocalPayload } from './local-encryption.js';
|
|
6
5
|
import { TEAM_SCHEMA } from './team-schema.js';
|
|
7
6
|
import { diagnoseMissingMembership, explainTeamConnectionError } from './team-diagnostics.js';
|
|
7
|
+
import { rigourUserDir } from '../utils/user-state.js';
|
|
8
8
|
import { TEAM_VECTOR_SCHEMA, backfillConfiguredTeamEmbeddings, getTeamSemanticHealth, searchConfiguredTeamKnowledge, upsertTeamLessonEmbedding, } from './team-vector-store.js';
|
|
9
9
|
const DEFAULT_SEMANTIC = {
|
|
10
10
|
provider: 'pgvector',
|
|
11
11
|
model: 'Xenova/all-MiniLM-L6-v2',
|
|
12
12
|
dimensions: 384,
|
|
13
13
|
};
|
|
14
|
-
export const TEAM_CONFIG_PATH = path.join(
|
|
14
|
+
export const TEAM_CONFIG_PATH = path.join(rigourUserDir(), 'team.json');
|
|
15
15
|
function nonEmpty(value) {
|
|
16
16
|
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
17
17
|
}
|
package/dist/utils/user-state.js
CHANGED
|
@@ -13,7 +13,7 @@ export function rigourUserDir() {
|
|
|
13
13
|
export function repoStateDir(cwd) {
|
|
14
14
|
let root = path.resolve(cwd);
|
|
15
15
|
try {
|
|
16
|
-
root = fs.realpathSync(root);
|
|
16
|
+
root = fs.realpathSync.native(root);
|
|
17
17
|
}
|
|
18
18
|
catch { /* not created yet: the resolved path is the key */ }
|
|
19
19
|
const key = crypto.createHash('sha256').update(root).digest('hex').slice(0, 16);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigour-labs/core",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.6.0",
|
|
4
4
|
"description": "Rigour's review engine: deterministic gates on changed lines, rules and lessons learned from your team's fixes, and per-check precision from what you fix versus dismiss, 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.
|
|
74
|
-
"@rigour-labs/brain-darwin-x64": "6.
|
|
75
|
-
"@rigour-labs/brain-linux-arm64": "6.
|
|
76
|
-
"@rigour-labs/brain-linux-x64": "6.
|
|
77
|
-
"@rigour-labs/brain-win-x64": "6.
|
|
73
|
+
"@rigour-labs/brain-darwin-arm64": "6.6.0",
|
|
74
|
+
"@rigour-labs/brain-darwin-x64": "6.6.0",
|
|
75
|
+
"@rigour-labs/brain-linux-arm64": "6.6.0",
|
|
76
|
+
"@rigour-labs/brain-linux-x64": "6.6.0",
|
|
77
|
+
"@rigour-labs/brain-win-x64": "6.6.0"
|
|
78
78
|
},
|
|
79
79
|
"devDependencies": {
|
|
80
80
|
"@types/fs-extra": "^11.0.4",
|