@rigour-labs/core 6.5.4 → 6.6.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/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/deep/risk.d.ts +6 -3
- package/dist/deep/risk.js +19 -3
- package/dist/deep/risk.test.js +28 -1
- package/dist/deep/router.d.ts +2 -1
- package/dist/deep/router.js +2 -2
- 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/gates/deep-analysis.js +3 -3
- package/dist/hooks/checker.js +18 -0
- package/dist/hooks/checker.test.js +10 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +6 -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/receipt.d.ts +63 -0
- package/dist/review/receipt.js +73 -0
- package/dist/review/receipt.test.d.ts +1 -0
- package/dist/review/receipt.test.js +60 -0
- package/dist/review/review-task.d.ts +3 -1
- package/dist/review/review-task.js +5 -4
- package/dist/review/review-task.test.js +9 -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/review-learning/team-lessons.d.ts +4 -0
- package/dist/review-learning/team-lessons.js +9 -1
- 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/types/index.js +1 -1
- 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
|
*/
|
package/dist/deep/risk.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { RemovedBlock } from '../utils/diff.js';
|
|
2
|
+
import { type ReviewLesson } from '../review-learning/lessons.js';
|
|
2
3
|
/** Kinds of operation where a wrong line costs the most; matched on the function's text. */
|
|
3
4
|
export declare const SENSITIVE: ReadonlyArray<{
|
|
4
5
|
kind: string;
|
|
@@ -14,6 +15,8 @@ export interface RiskSignals {
|
|
|
14
15
|
removedGuard: boolean;
|
|
15
16
|
/** SENSITIVE kinds this function touches. */
|
|
16
17
|
sensitive: string[];
|
|
18
|
+
/** A verified team lesson about this file that names code this function uses. */
|
|
19
|
+
lesson?: string;
|
|
17
20
|
}
|
|
18
21
|
export interface FunctionRisk {
|
|
19
22
|
file: string;
|
|
@@ -22,7 +25,7 @@ export interface FunctionRisk {
|
|
|
22
25
|
end: number;
|
|
23
26
|
signals: RiskSignals;
|
|
24
27
|
score: number;
|
|
25
|
-
/** sha256 of the function's text
|
|
28
|
+
/** sha256 of the function's exact text (line endings normalised): any edit to it changes this. */
|
|
26
29
|
hash: string;
|
|
27
30
|
}
|
|
28
31
|
/**
|
|
@@ -30,8 +33,8 @@ export interface FunctionRisk {
|
|
|
30
33
|
* "rules only". The router reviews functions at or above `min_score`.
|
|
31
34
|
*/
|
|
32
35
|
export declare function scoreRisk(signals: RiskSignals): number;
|
|
33
|
-
/** Changed functions
|
|
34
|
-
export declare function rankChangedFunctions(cwd: string, focusLines: Record<string, number[]>, removed?: Record<string, RemovedBlock[]
|
|
36
|
+
/** Changed functions outside tests, riskiest first; `lessons` are the team's verified review lessons in play. */
|
|
37
|
+
export declare function rankChangedFunctions(cwd: string, focusLines: Record<string, number[]>, removed?: Record<string, RemovedBlock[]>, lessons?: ReviewLesson[]): FunctionRisk[];
|
|
35
38
|
/** The current text hash and span of a named function in a file, if the file parses and has it. */
|
|
36
39
|
export declare function findFunction(cwd: string, file: string, name: string): {
|
|
37
40
|
hash: string;
|
package/dist/deep/risk.js
CHANGED
|
@@ -12,7 +12,10 @@ import fs from 'fs';
|
|
|
12
12
|
import path from 'path';
|
|
13
13
|
import ts from 'typescript';
|
|
14
14
|
import { changedFunctions, isExported } from './changed-functions.js';
|
|
15
|
+
import { isSpecific } from '../review-learning/lessons.js';
|
|
15
16
|
const PARSEABLE = /\.(?:[cm]?[jt]sx?)$/i;
|
|
17
|
+
/** Tests are reviewed with the change, not routed: their fixtures read as data writes and money, and their callbacks have no callers. */
|
|
18
|
+
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$|(?:^|\/)__tests__\//i;
|
|
16
19
|
/** Kinds of operation where a wrong line costs the most; matched on the function's text. */
|
|
17
20
|
export const SENSITIVE = [
|
|
18
21
|
{ kind: 'data-write', pattern: /\.(?:insert|update|upsert|delete|rpc)\s*\(|\b(?:INSERT|UPDATE|DELETE)\s+(?:INTO\s+)?\w|\bON CONFLICT\b/ },
|
|
@@ -28,7 +31,9 @@ export const SENSITIVE = [
|
|
|
28
31
|
*/
|
|
29
32
|
export function scoreRisk(signals) {
|
|
30
33
|
// Strong signals clear the default bar (1) on their own; shape alone (size, nesting) never does.
|
|
34
|
+
// A mistake this team already fixed once is as strong a signal as a removed guard.
|
|
31
35
|
let score = signals.removedGuard ? 2 : 0;
|
|
36
|
+
score += signals.lesson ? 2 : 0;
|
|
32
37
|
score += signals.sensitive.includes('data-write') ? 2 : 0;
|
|
33
38
|
// Other sensitive kinds add, capped so a function that mentions everything does not drown the rest.
|
|
34
39
|
score += Math.min(2, signals.sensitive.filter(kind => kind !== 'data-write').length);
|
|
@@ -37,11 +42,11 @@ export function scoreRisk(signals) {
|
|
|
37
42
|
score += signals.exported && signals.async ? 0.5 : 0;
|
|
38
43
|
return score;
|
|
39
44
|
}
|
|
40
|
-
/** Changed functions
|
|
41
|
-
export function rankChangedFunctions(cwd, focusLines, removed = {}) {
|
|
45
|
+
/** Changed functions outside tests, riskiest first; `lessons` are the team's verified review lessons in play. */
|
|
46
|
+
export function rankChangedFunctions(cwd, focusLines, removed = {}, lessons = []) {
|
|
42
47
|
const ranked = [];
|
|
43
48
|
for (const [file, lines] of Object.entries(focusLines)) {
|
|
44
|
-
if (!PARSEABLE.test(file) || lines.length === 0)
|
|
49
|
+
if (!PARSEABLE.test(file) || TEST_FILE.test(file) || lines.length === 0)
|
|
45
50
|
continue;
|
|
46
51
|
let text;
|
|
47
52
|
try {
|
|
@@ -57,6 +62,9 @@ export function rankChangedFunctions(cwd, focusLines, removed = {}) {
|
|
|
57
62
|
const end = sourceFile.getLineAndCharacterOfPosition(fn.getEnd()).line + 1;
|
|
58
63
|
const added = lines.filter(l => l >= start && l <= end).map(l => textLines[l - 1] ?? '');
|
|
59
64
|
const signals = signalsOf(fn, sourceFile, start, end, removed[file] ?? [], added);
|
|
65
|
+
const lesson = lessonFor(file, fn.getText(sourceFile), lessons);
|
|
66
|
+
if (lesson)
|
|
67
|
+
signals.lesson = lesson;
|
|
60
68
|
ranked.push({ file, name: nameOf(fn, sourceFile), start, end, signals, score: scoreRisk(signals), hash: functionHash(fn.getText(sourceFile)) });
|
|
61
69
|
}
|
|
62
70
|
}
|
|
@@ -101,6 +109,14 @@ export function findFunction(cwd, file, name) {
|
|
|
101
109
|
export function functionHash(text) {
|
|
102
110
|
return crypto.createHash('sha256').update(text.replace(/\r\n/g, '\n')).digest('hex');
|
|
103
111
|
}
|
|
112
|
+
/** The first lesson about this file that names an identifier the function uses (only specific names link code). */
|
|
113
|
+
function lessonFor(file, body, lessons) {
|
|
114
|
+
const forFile = lessons.filter(l => l.file === file);
|
|
115
|
+
if (forFile.length === 0)
|
|
116
|
+
return undefined;
|
|
117
|
+
const used = new Set(body.match(/[A-Za-z_$][\w$]*/g) ?? []);
|
|
118
|
+
return forFile.find(l => l.symbols.some(s => isSpecific(s) && used.has(s)))?.text;
|
|
119
|
+
}
|
|
104
120
|
const GUARD = /\b(?:if|else|return|throw|break|continue|catch)\b|\?\?|\?\./g;
|
|
105
121
|
function guards(lines) {
|
|
106
122
|
return lines.reduce((count, line) => count + (line.match(GUARD)?.length ?? 0), 0);
|
package/dist/deep/risk.test.js
CHANGED
|
@@ -3,7 +3,7 @@ import os from 'os';
|
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
5
|
import { functionHash, rankChangedFunctions, scoreRisk } from './risk.js';
|
|
6
|
-
import { routeFiles } from './router.js';
|
|
6
|
+
import { DEFAULT_MIN_SCORE, routeFiles } from './router.js';
|
|
7
7
|
let dir;
|
|
8
8
|
beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'risk-')); });
|
|
9
9
|
afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); });
|
|
@@ -32,6 +32,33 @@ describe('rankChangedFunctions', () => {
|
|
|
32
32
|
expect([byName.label.start, byName.label.end]).toEqual([8, 10]);
|
|
33
33
|
});
|
|
34
34
|
});
|
|
35
|
+
describe('test files', () => {
|
|
36
|
+
it('are never routed, however their fixtures read', () => {
|
|
37
|
+
fs.mkdirSync(path.join(dir, '__tests__'));
|
|
38
|
+
for (const file of ['sync.test.ts', 'sync.spec.js', '__tests__/sync.ts'])
|
|
39
|
+
fs.writeFileSync(path.join(dir, file), SYNC);
|
|
40
|
+
expect(rankChangedFunctions(dir, { 'sync.test.ts': [4], 'sync.spec.js': [4], '__tests__/sync.ts': [4] })).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('team lessons as a risk signal', () => {
|
|
44
|
+
const lesson = (over) => ({
|
|
45
|
+
id: 'l1', text: 'Trim before comparing names.', file: 'sync.ts', symbols: ['trimStart'], state: 'verified',
|
|
46
|
+
evidence: [{ pr: 1, comment: 'c', author: 'r' }], createdAt: '', updatedAt: '', ...over,
|
|
47
|
+
});
|
|
48
|
+
const LABEL = 'function label(name) {\n return name.trimStart();\n}\n';
|
|
49
|
+
it('makes a plain function risky when a lesson for its file names a symbol it uses', () => {
|
|
50
|
+
fs.writeFileSync(path.join(dir, 'sync.ts'), LABEL);
|
|
51
|
+
const [f] = rankChangedFunctions(dir, { 'sync.ts': [2] }, {}, [lesson({})]);
|
|
52
|
+
expect(f.signals.lesson).toBe('Trim before comparing names.');
|
|
53
|
+
expect(f.score).toBeGreaterThanOrEqual(DEFAULT_MIN_SCORE);
|
|
54
|
+
});
|
|
55
|
+
it('ignores lessons for other files and lessons naming only common words', () => {
|
|
56
|
+
fs.writeFileSync(path.join(dir, 'sync.ts'), LABEL);
|
|
57
|
+
const other = rankChangedFunctions(dir, { 'sync.ts': [2] }, {}, [lesson({ file: 'other.ts' }), lesson({ symbols: ['name'] })]);
|
|
58
|
+
expect(other[0].signals.lesson).toBeUndefined();
|
|
59
|
+
expect(other[0].score).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
35
62
|
describe('scoreRisk', () => {
|
|
36
63
|
it('ranks a data write behind a removed guard above a plain helper', () => {
|
|
37
64
|
const risky = { exported: true, async: true, nesting: 2, lines: 30, removedGuard: true, sensitive: ['data-write', 'paging'] };
|
package/dist/deep/router.d.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import type { RemovedBlock } from '../utils/diff.js';
|
|
9
9
|
import { type ReviewedKey } from '../review/ledger.js';
|
|
10
|
+
import type { ReviewLesson } from '../review-learning/lessons.js';
|
|
10
11
|
export interface RouterPolicy {
|
|
11
12
|
enabled?: boolean;
|
|
12
13
|
min_score?: number;
|
|
@@ -21,7 +22,7 @@ export interface RouterStats {
|
|
|
21
22
|
}
|
|
22
23
|
export declare const DEFAULT_MIN_SCORE = 1;
|
|
23
24
|
export declare const DEFAULT_MAX_FUNCTIONS = 12;
|
|
24
|
-
export declare function routeFiles(cwd: string, files: string[], focusLines: Record<string, number[]>, removed?: Record<string, RemovedBlock[]>, policy?: RouterPolicy, reviewed?: ReviewedKey[]): {
|
|
25
|
+
export declare function routeFiles(cwd: string, files: string[], focusLines: Record<string, number[]>, removed?: Record<string, RemovedBlock[]>, policy?: RouterPolicy, reviewed?: ReviewedKey[], lessons?: ReviewLesson[]): {
|
|
25
26
|
files: Set<string>;
|
|
26
27
|
stats: RouterStats;
|
|
27
28
|
};
|
package/dist/deep/router.js
CHANGED
|
@@ -2,9 +2,9 @@ import { rankChangedFunctions } from './risk.js';
|
|
|
2
2
|
import { isReviewed } from '../review/ledger.js';
|
|
3
3
|
export const DEFAULT_MIN_SCORE = 1;
|
|
4
4
|
export const DEFAULT_MAX_FUNCTIONS = 12;
|
|
5
|
-
export function routeFiles(cwd, files, focusLines, removed = {}, policy = {}, reviewed = []) {
|
|
5
|
+
export function routeFiles(cwd, files, focusLines, removed = {}, policy = {}, reviewed = [], lessons = []) {
|
|
6
6
|
const inScope = Object.fromEntries(files.filter(f => focusLines[f]?.length).map(f => [f, focusLines[f]]));
|
|
7
|
-
const ranked = rankChangedFunctions(cwd, inScope, removed);
|
|
7
|
+
const ranked = rankChangedFunctions(cwd, inScope, removed, lessons);
|
|
8
8
|
const minScore = policy.min_score ?? DEFAULT_MIN_SCORE;
|
|
9
9
|
const risky = ranked.filter(f => f.score >= minScore);
|
|
10
10
|
const unreviewed = risky.filter(f => !isReviewed(reviewed, { file: f.file, function: f.name, hash: f.hash }));
|
|
@@ -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
|
+
});
|