@rigour-labs/core 6.3.0 → 6.4.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/README.md +1 -1
- package/dist/gates/hallucinated-imports/js-resolver.d.ts +7 -0
- package/dist/gates/hallucinated-imports/js-resolver.js +17 -1
- package/dist/gates/hallucinated-imports-sveltekit.test.js +24 -0
- package/dist/gates/semantic-bugs.test.js +5 -5
- package/dist/hooks/index.d.ts +2 -0
- package/dist/hooks/index.js +2 -0
- package/dist/hooks/stop-review.d.ts +25 -0
- package/dist/hooks/stop-review.js +31 -0
- package/dist/index.d.ts +6 -1
- package/dist/index.js +6 -1
- package/dist/inference/model-download.test.js +54 -2
- package/dist/inference/model-manager.d.ts +6 -0
- package/dist/inference/model-manager.js +58 -18
- package/dist/inference/types.d.ts +6 -2
- package/dist/inference/types.js +7 -3
- package/dist/review/agent-fixes.d.ts +18 -0
- package/dist/review/agent-fixes.js +118 -0
- package/dist/review/agent-fixes.test.d.ts +1 -0
- package/dist/review/agent-fixes.test.js +32 -0
- package/dist/review/changed-lines.d.ts +19 -0
- package/dist/review/changed-lines.js +22 -0
- package/dist/review/effectiveness.d.ts +34 -0
- package/dist/review/effectiveness.js +114 -0
- package/dist/review/effectiveness.test.d.ts +1 -0
- package/dist/review/effectiveness.test.js +42 -0
- package/dist/review/git-diff.d.ts +9 -0
- package/dist/review/git-diff.js +42 -0
- package/dist/review/review.d.ts +36 -0
- package/dist/review/review.js +49 -0
- package/dist/review/review.test.d.ts +1 -0
- package/dist/review/review.test.js +87 -0
- package/dist/semantic/learn/learn.d.ts +2 -0
- package/dist/semantic/learn/learn.js +18 -0
- package/dist/templates/universal-config.js +1 -1
- package/dist/types/index.js +2 -1
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -26,7 +26,7 @@ The core library powering [Rigour](https://rigour.run) — 27+ quality gates, fi
|
|
|
26
26
|
- **Dependency bloat** — unused deps, heavy alternatives (moment→dayjs), duplicate purpose packages.
|
|
27
27
|
- **Context-window artifacts**, inconsistent error handling, promise safety, deprecated APIs.
|
|
28
28
|
|
|
29
|
-
**Semantic Bugs (type-aware,
|
|
29
|
+
**Semantic Bugs (type-aware, on by default):** a TypeScript program with the checker and cross-file value tracing proves a defect before reporting it: credentials forwarded on redirect, paged reads aggregated in memory, cached responses that can carry a failure fallback. Rules learned from fixes (`rigour learn`) run alongside them from `.rigour/rules/`.
|
|
30
30
|
|
|
31
31
|
**Agent Governance:** Multi-agent scope isolation, EWMA-based checkpoint supervision, context drift, retry loop breaker, memory & skills governance with DLP scanning.
|
|
32
32
|
|
|
@@ -29,6 +29,13 @@ export interface JsImportContext {
|
|
|
29
29
|
* Check JavaScript/TypeScript imports in a source file and add hallucinated findings.
|
|
30
30
|
*/
|
|
31
31
|
export declare function checkJSImports(content: string, file: string, ctx: JsImportContext): Promise<void>;
|
|
32
|
+
/**
|
|
33
|
+
* Bundler conventions: a query on a relative import (`./logo.svg?raw`, `./worker.ts?worker`)
|
|
34
|
+
* names a transform of that file, so the file is what must exist; a `virtual:` module or a
|
|
35
|
+
* bare specifier with a query (`page.tsx?tsr-split=component`) is produced by a plugin at
|
|
36
|
+
* build time and cannot be checked. Returns the path to check, or null to skip.
|
|
37
|
+
*/
|
|
38
|
+
export declare function bundlerSpecifier(importPath: string): string | null;
|
|
32
39
|
/**
|
|
33
40
|
* Collect all import statements from JavaScript/TypeScript file content.
|
|
34
41
|
* Handles import, export, require(), and dynamic import() statements.
|
|
@@ -21,7 +21,9 @@ import { loadTsPathConfig } from './tsconfig-paths.js';
|
|
|
21
21
|
export async function checkJSImports(content, file, ctx) {
|
|
22
22
|
const depsForFile = await resolveJSDepsForFile(file, ctx.cwd, ctx.rootDeps, ctx.depCacheByDir);
|
|
23
23
|
const kitRoot = await ctx.kitRoots.rootFor(file);
|
|
24
|
-
for (const
|
|
24
|
+
for (const spec of collectJSImportSpecs(content, file)) {
|
|
25
|
+
const { line } = spec;
|
|
26
|
+
const importPath = bundlerSpecifier(spec.importPath);
|
|
25
27
|
if (!importPath || ctx.shouldIgnore(importPath))
|
|
26
28
|
continue;
|
|
27
29
|
if (kitRoot && isSvelteKitProvided(importPath))
|
|
@@ -38,6 +40,20 @@ export async function checkJSImports(content, file, ctx) {
|
|
|
38
40
|
}
|
|
39
41
|
}
|
|
40
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Bundler conventions: a query on a relative import (`./logo.svg?raw`, `./worker.ts?worker`)
|
|
45
|
+
* names a transform of that file, so the file is what must exist; a `virtual:` module or a
|
|
46
|
+
* bare specifier with a query (`page.tsx?tsr-split=component`) is produced by a plugin at
|
|
47
|
+
* build time and cannot be checked. Returns the path to check, or null to skip.
|
|
48
|
+
*/
|
|
49
|
+
export function bundlerSpecifier(importPath) {
|
|
50
|
+
if (!importPath || importPath.startsWith('virtual:') || importPath.startsWith('\0'))
|
|
51
|
+
return null;
|
|
52
|
+
const query = importPath.search(/[?#]/);
|
|
53
|
+
if (query < 0)
|
|
54
|
+
return importPath;
|
|
55
|
+
return importPath.startsWith('.') ? importPath.slice(0, query) : null;
|
|
56
|
+
}
|
|
41
57
|
async function checkRelativeImport(file, importPath, ctx) {
|
|
42
58
|
if (ctx.resolveRelativeImport(file, importPath, ctx.projectFiles))
|
|
43
59
|
return null;
|
|
@@ -106,3 +106,27 @@ describe('HallucinatedImportsGate — tsconfig inheritance and rootDirs', () =>
|
|
|
106
106
|
expect(await missingImports(cwd)).toContain("'./nowhere'");
|
|
107
107
|
});
|
|
108
108
|
});
|
|
109
|
+
describe('HallucinatedImportsGate — bundler query and virtual imports', () => {
|
|
110
|
+
let cwd;
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'bundler-imports-'));
|
|
113
|
+
write(cwd, 'package.json', JSON.stringify({ dependencies: { '@tanstack/react-router': '1.0.0' } }));
|
|
114
|
+
write(cwd, 'src/logo.svg', '<svg/>');
|
|
115
|
+
write(cwd, 'src/worker.ts', 'export {};\n');
|
|
116
|
+
});
|
|
117
|
+
afterEach(() => { fs.rmSync(cwd, { recursive: true, force: true }); });
|
|
118
|
+
it('checks the file behind a query and skips plugin-provided modules', async () => {
|
|
119
|
+
write(cwd, 'src/route.tsx', [
|
|
120
|
+
"import raw from './logo.svg?raw';",
|
|
121
|
+
"import Worker from './worker.ts?worker&inline';",
|
|
122
|
+
"const split = () => import('route.tsx?tsr-split=component');",
|
|
123
|
+
"import { registerSW } from 'virtual:pwa-register';",
|
|
124
|
+
'export default { raw, Worker, split, registerSW };',
|
|
125
|
+
].join('\n'));
|
|
126
|
+
expect(await missingImports(cwd)).toBe('');
|
|
127
|
+
});
|
|
128
|
+
it('still reports a missing module behind a query', async () => {
|
|
129
|
+
write(cwd, 'src/route.tsx', "import Worker from './missing-worker.ts?worker';\nexport default Worker;\n");
|
|
130
|
+
expect(await missingImports(cwd)).toContain('./missing-worker.ts');
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -4,6 +4,7 @@ import path from 'path';
|
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
5
5
|
import { SemanticBugsGate } from './semantic-bugs.js';
|
|
6
6
|
import { GateRunner } from './runner.js';
|
|
7
|
+
import { ConfigSchema } from '../types/index.js';
|
|
7
8
|
const LEAKY = [
|
|
8
9
|
'export async function notify(endpoint: string, signature: string) {',
|
|
9
10
|
" return fetch(endpoint, { method: 'POST', headers: { 'x-hook-signature': signature } });",
|
|
@@ -59,12 +60,11 @@ describe('SemanticBugsGate', () => {
|
|
|
59
60
|
expect(failure.title).toContain('[learned/require-option-redirect-abcd1234]');
|
|
60
61
|
expect(failure.hint).toContain('fix abcdef01 in src/http.ts (post)');
|
|
61
62
|
});
|
|
62
|
-
it('is
|
|
63
|
+
it('is on by default and can be turned off in rigour.yml', async () => {
|
|
63
64
|
write('src/notify.ts', LEAKY);
|
|
64
|
-
const
|
|
65
|
-
const off = await new GateRunner(base).run(cwd);
|
|
66
|
-
expect(off.failures.some(f => f.id === 'semantic-bugs')).toBe(false);
|
|
67
|
-
const on = await new GateRunner({ ...base, gates: { ...base.gates, semantic_bugs: { enabled: true } } }).run(cwd);
|
|
65
|
+
const on = await new GateRunner(ConfigSchema.parse({ version: 1 })).run(cwd);
|
|
68
66
|
expect(on.failures.some(f => f.id === 'semantic-bugs')).toBe(true);
|
|
67
|
+
const off = await new GateRunner(ConfigSchema.parse({ version: 1, gates: { semantic_bugs: { enabled: false } } })).run(cwd);
|
|
68
|
+
expect(off.failures.some(f => f.id === 'semantic-bugs')).toBe(false);
|
|
69
69
|
});
|
|
70
70
|
});
|
package/dist/hooks/index.d.ts
CHANGED
|
@@ -12,3 +12,5 @@ export { recordDLPFeedback, allowLastDLPBlock, writeDLPBlockManifest, getLearned
|
|
|
12
12
|
export type { DLPFeedbackEntry, DLPFeedbackStore, DLPBlockManifest } from './dlp-feedback.js';
|
|
13
13
|
export { generateDLPHookFiles } from './dlp-templates.js';
|
|
14
14
|
export type { GeneratedDLPHookFile } from './dlp-templates.js';
|
|
15
|
+
export { stopReview, stopMessage, blocksStop, STOP_MAX_ATTEMPTS } from './stop-review.js';
|
|
16
|
+
export type { StopDecision } from './stop-review.js';
|
package/dist/hooks/index.js
CHANGED
|
@@ -9,3 +9,5 @@ export { DEFAULT_HOOK_CONFIG, FAST_GATE_IDS } from './types.js';
|
|
|
9
9
|
export { scanInputForCredentials, formatDLPAlert, createDLPAuditEntry } from './input-validator.js';
|
|
10
10
|
export { recordDLPFeedback, allowLastDLPBlock, writeDLPBlockManifest, getLearnedAllowCount, loadDLPFeedbackStore, loadDLPFeedbackStoreSync, fingerprintDetection, } from './dlp-feedback.js';
|
|
11
11
|
export { generateDLPHookFiles } from './dlp-templates.js';
|
|
12
|
+
// Stop review ("before you say done")
|
|
13
|
+
export { stopReview, stopMessage, blocksStop, STOP_MAX_ATTEMPTS } from './stop-review.js';
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "before you say done" review: when an agent is about to stop, review the
|
|
3
|
+
* uncommitted change and, if it introduced serious findings, tell the agent
|
|
4
|
+
* what to fix instead of letting it finish.
|
|
5
|
+
*
|
|
6
|
+
* Only findings that deserve it block: critical ones, and high ones that are
|
|
7
|
+
* proven (the semantic engine traced them) or security findings. A high
|
|
8
|
+
* heuristic (a regex that sees `fetch` without `.catch`) is not enough to hold
|
|
9
|
+
* an agent back; callers also cap the number of attempts.
|
|
10
|
+
*/
|
|
11
|
+
import type { Config, Failure } from '../types/index.js';
|
|
12
|
+
export declare const STOP_MAX_ATTEMPTS = 3;
|
|
13
|
+
export interface StopDecision {
|
|
14
|
+
block: boolean;
|
|
15
|
+
/** What the agent is told when blocked; empty otherwise. */
|
|
16
|
+
message: string;
|
|
17
|
+
blocking: number;
|
|
18
|
+
/** Every finding on changed lines, and the files the review covered (for fix capture). */
|
|
19
|
+
findings: Failure[];
|
|
20
|
+
reviewedFiles: string[];
|
|
21
|
+
}
|
|
22
|
+
export declare function stopReview(cwd: string, config: Config, attempt: number): Promise<StopDecision>;
|
|
23
|
+
/** Critical, or high and either proven by the semantic engine or a security finding. */
|
|
24
|
+
export declare function blocksStop(finding: Failure): boolean;
|
|
25
|
+
export declare function stopMessage(findings: Failure[], attempt: number): string;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { reviewChange } from '../review/review.js';
|
|
2
|
+
export const STOP_MAX_ATTEMPTS = 3;
|
|
3
|
+
const MAX_LISTED = 8;
|
|
4
|
+
export async function stopReview(cwd, config, attempt) {
|
|
5
|
+
const result = await reviewChange({ cwd, config, source: { mode: 'working' } });
|
|
6
|
+
const blocking = result.findings.filter(blocksStop);
|
|
7
|
+
const reviewed = { findings: result.findings, reviewedFiles: Object.keys(result.changedLines) };
|
|
8
|
+
if (blocking.length === 0)
|
|
9
|
+
return { block: false, message: '', blocking: 0, ...reviewed };
|
|
10
|
+
return { block: true, message: stopMessage(blocking, attempt), blocking: blocking.length, ...reviewed };
|
|
11
|
+
}
|
|
12
|
+
/** Critical, or high and either proven by the semantic engine or a security finding. */
|
|
13
|
+
export function blocksStop(finding) {
|
|
14
|
+
const severity = (finding.severity || 'medium');
|
|
15
|
+
if (severity === 'critical')
|
|
16
|
+
return true;
|
|
17
|
+
return severity === 'high' && (finding.verified === true || finding.provenance === 'security');
|
|
18
|
+
}
|
|
19
|
+
export function stopMessage(findings, attempt) {
|
|
20
|
+
const listed = findings.slice(0, MAX_LISTED).map(f => {
|
|
21
|
+
const where = `${f.files?.[0] ?? '?'}:${f.line ?? '?'}`;
|
|
22
|
+
return `- [${(f.severity || 'medium').toUpperCase()}] ${where} ${f.title}${f.hint ? `\n Fix: ${f.hint}` : ''}`;
|
|
23
|
+
});
|
|
24
|
+
const more = findings.length > MAX_LISTED ? [`- …and ${findings.length - MAX_LISTED} more (run \`rigour review\`).`] : [];
|
|
25
|
+
return [
|
|
26
|
+
`Rigour review found ${findings.length} serious issue(s) on the lines you changed (attempt ${attempt} of ${STOP_MAX_ATTEMPTS}).`,
|
|
27
|
+
'Fix them before finishing, or explain why a finding is wrong:',
|
|
28
|
+
...listed,
|
|
29
|
+
...more,
|
|
30
|
+
].join('\n');
|
|
31
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -11,6 +11,11 @@ export { FrontendSecretExposureGate } from './gates/frontend-secret-exposure.js'
|
|
|
11
11
|
export * from './utils/logger.js';
|
|
12
12
|
export { normalizeScopePatterns, isScoped } from './utils/scope.js';
|
|
13
13
|
export { deepAnalysisError } from './utils/deep-status.js';
|
|
14
|
+
export { reviewChange, toReviewFinding, type ReviewInput, type ReviewResult, type ReviewFinding } from './review/review.js';
|
|
15
|
+
export { splitByChangedLines, type ChangedLineSplit } from './review/changed-lines.js';
|
|
16
|
+
export { diffFromGit, GitDiffError, type DiffSource } from './review/git-diff.js';
|
|
17
|
+
export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount, type ResolvedFix, type FixCapture } from './review/agent-fixes.js';
|
|
18
|
+
export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys, type AgentEvent, type ReviewEffectiveness } from './review/effectiveness.js';
|
|
14
19
|
export { parseDiff, changedLinesByFile } from './utils/diff.js';
|
|
15
20
|
export { FileScanner } from './utils/scanner.js';
|
|
16
21
|
export * from './services/score-history.js';
|
|
@@ -51,7 +56,7 @@ export * from './services/system-health.js';
|
|
|
51
56
|
export * from './services/agent-history.js';
|
|
52
57
|
export * from './services/engineering-knowledge-graph.js';
|
|
53
58
|
export * from './firewall/index.js';
|
|
54
|
-
export { learnFromFix, type LearnReport, type Rejection } from './semantic/learn/learn.js';
|
|
59
|
+
export { learnFromFix, learnFromFileChange, type LearnReport, type Rejection } from './semantic/learn/learn.js';
|
|
55
60
|
export { extractFixTrees, type FixTrees } from './semantic/learn/git-trees.js';
|
|
56
61
|
export { saveLearnedRule, loadLearnedRules, LEARNED_RULES_DIR } from './semantic/learn/store.js';
|
|
57
62
|
export type { LearnedRule, LearnedPattern } from './semantic/learn/types.js';
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,11 @@ export { FrontendSecretExposureGate } from './gates/frontend-secret-exposure.js'
|
|
|
11
11
|
export * from './utils/logger.js';
|
|
12
12
|
export { normalizeScopePatterns, isScoped } from './utils/scope.js';
|
|
13
13
|
export { deepAnalysisError } from './utils/deep-status.js';
|
|
14
|
+
export { reviewChange, toReviewFinding } from './review/review.js';
|
|
15
|
+
export { splitByChangedLines } from './review/changed-lines.js';
|
|
16
|
+
export { diffFromGit, GitDiffError } from './review/git-diff.js';
|
|
17
|
+
export { recordReviewOutcome, listResolvedFixes, removeResolvedFix, openFindingCount } from './review/agent-fixes.js';
|
|
18
|
+
export { computeEffectiveness, readAgentEvents, appendAgentEvent, findingKeys } from './review/effectiveness.js';
|
|
14
19
|
export { parseDiff, changedLinesByFile } from './utils/diff.js';
|
|
15
20
|
export { FileScanner } from './utils/scanner.js';
|
|
16
21
|
export * from './services/score-history.js';
|
|
@@ -50,7 +55,7 @@ export * from './services/system-health.js';
|
|
|
50
55
|
export * from './services/agent-history.js';
|
|
51
56
|
export * from './services/engineering-knowledge-graph.js';
|
|
52
57
|
export * from './firewall/index.js';
|
|
53
|
-
export { learnFromFix } from './semantic/learn/learn.js';
|
|
58
|
+
export { learnFromFix, learnFromFileChange } from './semantic/learn/learn.js';
|
|
54
59
|
export { extractFixTrees } from './semantic/learn/git-trees.js';
|
|
55
60
|
export { saveLearnedRule, loadLearnedRules, LEARNED_RULES_DIR } from './semantic/learn/store.js';
|
|
56
61
|
export { recordContextEvent, recordModelUsage, recordCheckpointMetric, getContextEvents, getModelUsages, getCheckpointMetrics, } from './storage/index.js';
|
|
@@ -9,7 +9,7 @@ const home = vi.hoisted(() => {
|
|
|
9
9
|
return p.join(o.tmpdir(), `rigour-models-${process.pid}-${Date.now()}`);
|
|
10
10
|
});
|
|
11
11
|
vi.mock('../storage/db.js', () => ({ RIGOUR_DIR: home }));
|
|
12
|
-
const { downloadModel, getCachedModel } = await import('./model-manager.js');
|
|
12
|
+
const { checkForUpdates, downloadModel, getCachedModel, normalizeModelVersion } = await import('./model-manager.js');
|
|
13
13
|
const { FALLBACK_MODELS, MODELS } = await import('./types.js');
|
|
14
14
|
const modelsDir = path.join(home, 'models');
|
|
15
15
|
const PAYLOAD = Buffer.from('gguf-model-bytes');
|
|
@@ -22,7 +22,7 @@ describe('downloadModel', () => {
|
|
|
22
22
|
beforeEach(async () => {
|
|
23
23
|
await fs.remove(home);
|
|
24
24
|
// A fresh version check: no network call to Hugging Face.
|
|
25
|
-
await fs.outputJson(path.join(modelsDir, '.latest_version.json'), { version: '
|
|
25
|
+
await fs.outputJson(path.join(modelsDir, '.latest_version.json'), { format: 2, version: '5', checkedAt: new Date().toISOString() });
|
|
26
26
|
});
|
|
27
27
|
afterAll(async () => { await fs.remove(home); });
|
|
28
28
|
it('falls back to the stock model when the fine-tuned model is unavailable, and says so', async () => {
|
|
@@ -39,6 +39,19 @@ describe('downloadModel', () => {
|
|
|
39
39
|
const meta = await fs.readJson(`${modelPath}.meta.json`);
|
|
40
40
|
expect(meta).toMatchObject({ sha256: PAYLOAD_SHA, sizeBytes: PAYLOAD.length, sourceUrl: FALLBACK_MODELS.lite.url });
|
|
41
41
|
});
|
|
42
|
+
it('retries the fine-tuned model once a day when only the stock fallback is cached', async () => {
|
|
43
|
+
const failing = (url) => (url.includes('rigour-labs/') ? unauthorized : ok(PAYLOAD, PAYLOAD_SHA));
|
|
44
|
+
const stockPath = await downloadModel('lite', undefined, failing);
|
|
45
|
+
const requested = [];
|
|
46
|
+
const again = await downloadModel('lite', undefined, (url) => { requested.push(url); return unauthorized; });
|
|
47
|
+
expect(requested).toEqual([]); // failed less than a day ago: keep the fallback, no request
|
|
48
|
+
expect(again).toBe(stockPath);
|
|
49
|
+
await fs.writeJson(path.join(modelsDir, '.fine-tuned-unavailable-lite.json'), { failedAt: '2000-01-01T00:00:00Z', url: MODELS.lite.url });
|
|
50
|
+
const fineTuned = await downloadModel('lite', undefined, (url) => { requested.push(url); return ok(PAYLOAD, PAYLOAD_SHA); });
|
|
51
|
+
expect(requested).toEqual([MODELS.lite.url]);
|
|
52
|
+
expect(path.basename(fineTuned)).toBe(MODELS.lite.filename);
|
|
53
|
+
expect(await fs.pathExists(path.join(modelsDir, '.fine-tuned-unavailable-lite.json'))).toBe(false);
|
|
54
|
+
});
|
|
42
55
|
it('rejects a checksum mismatch and deletes the partial download', async () => {
|
|
43
56
|
const get = () => ok(PAYLOAD, 'f'.repeat(64));
|
|
44
57
|
await expect(downloadModel('lite', undefined, get)).rejects.toThrow('Checksum mismatch');
|
|
@@ -65,9 +78,48 @@ describe('getCachedModel', () => {
|
|
|
65
78
|
const cached = await getCachedModel('deep');
|
|
66
79
|
expect(cached).toMatchObject({ fallback: true, info: { filename: FALLBACK_MODELS.deep.filename } });
|
|
67
80
|
});
|
|
81
|
+
it('accepts a verified fine-tuned file smaller than the size estimate', async () => {
|
|
82
|
+
await cacheFile(MODELS.lite.filename, 397_807_360); // the published v5 lite model
|
|
83
|
+
expect(await getCachedModel('lite')).toMatchObject({ fallback: false, info: { filename: MODELS.lite.filename } });
|
|
84
|
+
});
|
|
85
|
+
it('accepts a file verified within the same millisecond it was written', async () => {
|
|
86
|
+
const file = path.join(modelsDir, FALLBACK_MODELS.deep.filename);
|
|
87
|
+
await cacheFile(FALLBACK_MODELS.deep.filename, 10);
|
|
88
|
+
const written = new Date('2026-01-01T00:00:00.000Z');
|
|
89
|
+
await fs.utimes(file, written, written.getTime() / 1000 + 0.0007); // mtime 0.7 ms into the same millisecond
|
|
90
|
+
await fs.writeJson(`${file}.meta.json`, { sha256: 'a'.repeat(64), sizeBytes: 10, verifiedAt: written.toISOString(), sourceUrl: 'https://x' });
|
|
91
|
+
expect(await getCachedModel('deep')).toMatchObject({ fallback: true });
|
|
92
|
+
});
|
|
93
|
+
it('rejects a file modified after it was verified', async () => {
|
|
94
|
+
const file = path.join(modelsDir, FALLBACK_MODELS.deep.filename);
|
|
95
|
+
await cacheFile(FALLBACK_MODELS.deep.filename, 10);
|
|
96
|
+
await fs.writeJson(`${file}.meta.json`, { sha256: 'a'.repeat(64), sizeBytes: 10, verifiedAt: '2000-01-01T00:00:00.000Z', sourceUrl: 'https://x' });
|
|
97
|
+
expect(await getCachedModel('deep')).toBeNull();
|
|
98
|
+
});
|
|
68
99
|
it('ignores a file without verified metadata', async () => {
|
|
69
100
|
await fs.ensureDir(modelsDir);
|
|
70
101
|
await fs.writeFile(path.join(modelsDir, FALLBACK_MODELS.deep.filename), 'partial');
|
|
71
102
|
expect(await getCachedModel('deep')).toBeNull();
|
|
72
103
|
});
|
|
73
104
|
});
|
|
105
|
+
describe('model versions', () => {
|
|
106
|
+
beforeEach(async () => { await fs.remove(home); vi.unstubAllGlobals(); });
|
|
107
|
+
it('keeps published version strings as they are', () => {
|
|
108
|
+
expect(normalizeModelVersion(5)).toBe('5');
|
|
109
|
+
expect(normalizeModelVersion('5')).toBe('5');
|
|
110
|
+
expect(normalizeModelVersion('v2.1.0')).toBe('2.1.0');
|
|
111
|
+
expect(normalizeModelVersion('5.0')).toBeNull();
|
|
112
|
+
expect(normalizeModelVersion({})).toBeNull();
|
|
113
|
+
});
|
|
114
|
+
it('names the published v5 repositories by default', () => {
|
|
115
|
+
expect(MODELS.lite.url).toBe('https://huggingface.co/rigour-labs/rigour-lite-v5-gguf/resolve/main/rigour-lite-v5-q4_k_m.gguf');
|
|
116
|
+
expect(MODELS.deep.url).toBe('https://huggingface.co/rigour-labs/rigour-deep-v5-gguf/resolve/main/rigour-deep-v5-q4_k_m.gguf');
|
|
117
|
+
});
|
|
118
|
+
it('ignores a version cache from the old SemVer conversion and re-reads the published version', async () => {
|
|
119
|
+
await fs.outputJson(path.join(modelsDir, '.latest_version.json'), { version: '5.0.0', checkedAt: new Date().toISOString() });
|
|
120
|
+
vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, json: async () => ({ version: 5 }) })));
|
|
121
|
+
expect(await checkForUpdates()).toBe('5');
|
|
122
|
+
expect(await fs.readJson(path.join(modelsDir, '.latest_version.json'))).toMatchObject({ format: 2, version: '5' });
|
|
123
|
+
expect(MODELS.deep.filename).toBe('rigour-deep-v5-q4_k_m.gguf');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -26,6 +26,12 @@ export declare function getModelPath(tier: ModelTier): string;
|
|
|
26
26
|
* Get model info for a tier.
|
|
27
27
|
*/
|
|
28
28
|
export declare function getModelInfo(tier: ModelTier): ModelInfo;
|
|
29
|
+
/**
|
|
30
|
+
* The version exactly as published. Integer versions (the RLAIF pipeline
|
|
31
|
+
* before SemVer) are published as `v5`, SemVer versions as `v2.1.0`; either
|
|
32
|
+
* way the string goes into the repository and file names unchanged.
|
|
33
|
+
*/
|
|
34
|
+
export declare function normalizeModelVersion(raw: unknown): string | null;
|
|
29
35
|
/**
|
|
30
36
|
* Check HuggingFace for a newer model version (like antivirus signature updates).
|
|
31
37
|
* Reads latest_version.json from the RLAIF dataset repo. Non-blocking — if the
|
|
@@ -8,6 +8,7 @@ import { createHash } from 'crypto';
|
|
|
8
8
|
import { RIGOUR_DIR } from '../storage/db.js';
|
|
9
9
|
import { downloadToFile } from './http-download.js';
|
|
10
10
|
import { MODELS, FALLBACK_MODELS, VERSION_CHECK_URL, BUNDLED_MODEL_VERSION, updateModelVersion } from './types.js';
|
|
11
|
+
import { Logger } from '../utils/logger.js';
|
|
11
12
|
const MODELS_DIR = path.join(RIGOUR_DIR, 'models');
|
|
12
13
|
const VERSION_CACHE_PATH = path.join(MODELS_DIR, '.latest_version.json');
|
|
13
14
|
const VERSION_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // Check once per day
|
|
@@ -63,13 +64,14 @@ async function isFileCached(model) {
|
|
|
63
64
|
const metadata = await readModelMeta(model.filename);
|
|
64
65
|
if (!metadata)
|
|
65
66
|
return false;
|
|
67
|
+
// The metadata is written only after the checksum passed, so its exact size is
|
|
68
|
+
// the completeness check; ModelInfo sizes are display estimates that differ by version.
|
|
66
69
|
const stat = await fs.stat(modelPath);
|
|
67
|
-
const tolerance = model.sizeBytes * 0.1;
|
|
68
|
-
if (stat.size <= model.sizeBytes - tolerance)
|
|
69
|
-
return false;
|
|
70
70
|
if (metadata.sizeBytes !== stat.size)
|
|
71
71
|
return false;
|
|
72
|
-
|
|
72
|
+
// verifiedAt has millisecond precision and mtimeMs a sub-millisecond fraction: compare
|
|
73
|
+
// whole milliseconds, or a file verified within the same millisecond looks modified.
|
|
74
|
+
if (new Date(metadata.verifiedAt).getTime() < Math.floor(stat.mtimeMs))
|
|
73
75
|
return false;
|
|
74
76
|
return true;
|
|
75
77
|
}
|
|
@@ -161,6 +163,27 @@ async function downloadFromUrl(model, onProgress, get) {
|
|
|
161
163
|
onProgress?.(`Model ${model.name} ready`, 100);
|
|
162
164
|
return destPath;
|
|
163
165
|
}
|
|
166
|
+
/**
|
|
167
|
+
* Version cache format. Format 1 turned a published integer version into
|
|
168
|
+
* SemVer ("5" -> "5.0.0"), which names repositories that were never published,
|
|
169
|
+
* so those caches are ignored.
|
|
170
|
+
*/
|
|
171
|
+
const VERSION_CACHE_FORMAT = 2;
|
|
172
|
+
/** How long a failed fine-tuned download keeps the stock fallback before retrying. */
|
|
173
|
+
const FINE_TUNED_RETRY_MS = 24 * 60 * 60 * 1000;
|
|
174
|
+
/**
|
|
175
|
+
* The version exactly as published. Integer versions (the RLAIF pipeline
|
|
176
|
+
* before SemVer) are published as `v5`, SemVer versions as `v2.1.0`; either
|
|
177
|
+
* way the string goes into the repository and file names unchanged.
|
|
178
|
+
*/
|
|
179
|
+
export function normalizeModelVersion(raw) {
|
|
180
|
+
if (typeof raw === 'number' && Number.isInteger(raw) && raw > 0)
|
|
181
|
+
return String(raw);
|
|
182
|
+
if (typeof raw !== 'string')
|
|
183
|
+
return null;
|
|
184
|
+
const value = raw.trim().replace(/^v/, '');
|
|
185
|
+
return /^\d+$/.test(value) || /^\d+\.\d+\.\d+$/.test(value) ? value : null;
|
|
186
|
+
}
|
|
164
187
|
/**
|
|
165
188
|
* Check HuggingFace for a newer model version (like antivirus signature updates).
|
|
166
189
|
* Reads latest_version.json from the RLAIF dataset repo. Non-blocking — if the
|
|
@@ -175,8 +198,8 @@ export async function checkForUpdates(onProgress) {
|
|
|
175
198
|
if (await fs.pathExists(VERSION_CACHE_PATH)) {
|
|
176
199
|
const cached = await fs.readJson(VERSION_CACHE_PATH);
|
|
177
200
|
const age = Date.now() - new Date(cached.checkedAt).getTime();
|
|
178
|
-
|
|
179
|
-
|
|
201
|
+
const v = cached.format === VERSION_CACHE_FORMAT ? normalizeModelVersion(cached.version) : null;
|
|
202
|
+
if (age < VERSION_CHECK_INTERVAL_MS && v) {
|
|
180
203
|
updateModelVersion(v);
|
|
181
204
|
return v;
|
|
182
205
|
}
|
|
@@ -193,13 +216,10 @@ export async function checkForUpdates(onProgress) {
|
|
|
193
216
|
clearTimeout(timeout);
|
|
194
217
|
if (response.ok) {
|
|
195
218
|
const data = await response.json();
|
|
196
|
-
|
|
197
|
-
let rawVersion = data.version ?? BUNDLED_MODEL_VERSION;
|
|
198
|
-
const latestVersion = typeof rawVersion === 'number'
|
|
199
|
-
? `${rawVersion}.0.0`
|
|
200
|
-
: String(rawVersion);
|
|
219
|
+
const latestVersion = normalizeModelVersion(data.version) ?? BUNDLED_MODEL_VERSION;
|
|
201
220
|
// Cache the result locally
|
|
202
221
|
await fs.writeJson(VERSION_CACHE_PATH, {
|
|
222
|
+
format: VERSION_CACHE_FORMAT,
|
|
203
223
|
version: latestVersion,
|
|
204
224
|
checkedAt: new Date().toISOString(),
|
|
205
225
|
source: 'huggingface',
|
|
@@ -226,22 +246,42 @@ export async function downloadModel(tier, onProgress, get) {
|
|
|
226
246
|
// Check for newer model version (non-blocking, cached 24h)
|
|
227
247
|
await checkForUpdates(onProgress);
|
|
228
248
|
const cached = await getCachedModel(tier);
|
|
229
|
-
if (cached) {
|
|
249
|
+
if (cached && !cached.fallback) {
|
|
230
250
|
onProgress?.(`Model ${cached.info.name} already cached`, 100);
|
|
231
251
|
return cached.path;
|
|
232
252
|
}
|
|
253
|
+
// Only the stock fallback is cached: try the fine-tuned model again, at most once a day,
|
|
254
|
+
// so one failed download does not pin a user to the stock model forever.
|
|
255
|
+
if (cached && !(await fineTunedRetryDue(tier)))
|
|
256
|
+
return cached.path;
|
|
233
257
|
const model = MODELS[tier];
|
|
234
258
|
onProgress?.(`Downloading ${model.name} (${model.sizeHuman})...`, 0);
|
|
235
259
|
try {
|
|
236
|
-
|
|
260
|
+
const downloaded = await downloadFromUrl(model, onProgress, get);
|
|
261
|
+
await fs.remove(retryMarker(tier)).catch(() => { });
|
|
262
|
+
return downloaded;
|
|
237
263
|
}
|
|
238
264
|
catch (error) {
|
|
239
265
|
const fallback = FALLBACK_MODELS[tier];
|
|
240
|
-
if (fallback
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
266
|
+
if (!fallback || fallback.url === model.url)
|
|
267
|
+
throw error;
|
|
268
|
+
const reason = error?.message ?? 'unknown error';
|
|
269
|
+
Logger.warn(`Fine-tuned model ${model.name} could not be downloaded from ${model.url} (${reason}); using the stock ${fallback.name}. Deep findings will be less accurate.`);
|
|
270
|
+
onProgress?.(`Fine-tuned model unavailable (${reason}); using stock ${fallback.name}`, 0);
|
|
271
|
+
await fs.writeJson(retryMarker(tier), { failedAt: new Date().toISOString(), url: model.url, reason }).catch(() => { });
|
|
272
|
+
return cached?.path ?? downloadFromUrl(fallback, onProgress, get);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function retryMarker(tier) {
|
|
276
|
+
return path.join(MODELS_DIR, `.fine-tuned-unavailable-${tier}.json`);
|
|
277
|
+
}
|
|
278
|
+
async function fineTunedRetryDue(tier) {
|
|
279
|
+
try {
|
|
280
|
+
const marker = await fs.readJson(retryMarker(tier));
|
|
281
|
+
return Date.now() - new Date(marker.failedAt).getTime() >= FINE_TUNED_RETRY_MS || marker.url !== MODELS[tier].url;
|
|
282
|
+
}
|
|
283
|
+
catch {
|
|
284
|
+
return true;
|
|
245
285
|
}
|
|
246
286
|
}
|
|
247
287
|
/**
|
|
@@ -86,12 +86,16 @@ export interface ModelInfo {
|
|
|
86
86
|
* At startup, model-manager checks HF for the latest version and downloads
|
|
87
87
|
* it automatically (like antivirus signature updates).
|
|
88
88
|
*
|
|
89
|
-
*
|
|
89
|
+
* The fallback is the last version actually published: v5, from the RLAIF
|
|
90
|
+
* pipeline's integer versions. SemVer versions (MAJOR.MINOR.PATCH) are
|
|
91
|
+
* published under their SemVer names.
|
|
92
|
+
*
|
|
93
|
+
* Version format: SemVer (MAJOR.MINOR.PATCH), or a legacy integer
|
|
90
94
|
* - MAJOR: Training data format change, base model change, pipeline architecture
|
|
91
95
|
* - MINOR: New training repos, updated dataset, hyperparameter improvements
|
|
92
96
|
* - PATCH: Bug fixes, retraining with same data/format
|
|
93
97
|
*/
|
|
94
|
-
export declare const BUNDLED_MODEL_VERSION = "
|
|
98
|
+
export declare const BUNDLED_MODEL_VERSION = "5";
|
|
95
99
|
/** HuggingFace dataset repo where latest_version.json lives */
|
|
96
100
|
export declare const VERSION_CHECK_URL = "https://huggingface.co/datasets/rigour-labs/rigour-rlaif-data/resolve/main/latest_version.json";
|
|
97
101
|
/** Build model info for a given tier and version */
|
package/dist/inference/types.js
CHANGED
|
@@ -5,19 +5,23 @@
|
|
|
5
5
|
* At startup, model-manager checks HF for the latest version and downloads
|
|
6
6
|
* it automatically (like antivirus signature updates).
|
|
7
7
|
*
|
|
8
|
-
*
|
|
8
|
+
* The fallback is the last version actually published: v5, from the RLAIF
|
|
9
|
+
* pipeline's integer versions. SemVer versions (MAJOR.MINOR.PATCH) are
|
|
10
|
+
* published under their SemVer names.
|
|
11
|
+
*
|
|
12
|
+
* Version format: SemVer (MAJOR.MINOR.PATCH), or a legacy integer
|
|
9
13
|
* - MAJOR: Training data format change, base model change, pipeline architecture
|
|
10
14
|
* - MINOR: New training repos, updated dataset, hyperparameter improvements
|
|
11
15
|
* - PATCH: Bug fixes, retraining with same data/format
|
|
12
16
|
*/
|
|
13
|
-
export const BUNDLED_MODEL_VERSION = '
|
|
17
|
+
export const BUNDLED_MODEL_VERSION = '5';
|
|
14
18
|
/** HuggingFace dataset repo where latest_version.json lives */
|
|
15
19
|
export const VERSION_CHECK_URL = 'https://huggingface.co/datasets/rigour-labs/rigour-rlaif-data/resolve/main/latest_version.json';
|
|
16
20
|
/** Build model info for a given tier and version */
|
|
17
21
|
export function buildModelInfo(tier, version) {
|
|
18
22
|
const meta = {
|
|
19
23
|
deep: { base: 'Qwen2.5-Coder-1.5B', size: 900_000_000, sizeH: '900MB' },
|
|
20
|
-
lite: { base: 'Qwen2.5-Coder-0.5B', size:
|
|
24
|
+
lite: { base: 'Qwen2.5-Coder-0.5B', size: 400_000_000, sizeH: '400MB' },
|
|
21
25
|
legacy: { base: 'Qwen2.5-Coder-0.5B', size: 350_000_000, sizeH: '350MB' },
|
|
22
26
|
};
|
|
23
27
|
const m = meta[tier];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Failure } from '../types/index.js';
|
|
2
|
+
export interface ResolvedFix {
|
|
3
|
+
id: string;
|
|
4
|
+
file: string;
|
|
5
|
+
rule: string;
|
|
6
|
+
before: string;
|
|
7
|
+
after: string;
|
|
8
|
+
resolvedAt: string;
|
|
9
|
+
}
|
|
10
|
+
export interface FixCapture {
|
|
11
|
+
opened: number;
|
|
12
|
+
resolved: number;
|
|
13
|
+
}
|
|
14
|
+
/** Record a review's findings: open new ones, resolve open ones the review no longer reports. */
|
|
15
|
+
export declare function recordReviewOutcome(cwd: string, findings: Failure[], reviewedFiles: string[]): FixCapture;
|
|
16
|
+
export declare function listResolvedFixes(cwd: string): ResolvedFix[];
|
|
17
|
+
export declare function removeResolvedFix(cwd: string, id: string): void;
|
|
18
|
+
export declare function openFindingCount(cwd: string): number;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixes agents make to Rigour findings, captured so they can become rules.
|
|
3
|
+
*
|
|
4
|
+
* Capture is cheap and happens in the review loop (MCP review, stop hook):
|
|
5
|
+
* a finding's file content is kept when the finding appears, and when a later
|
|
6
|
+
* review of that file no longer reports it, the before and after are stored as
|
|
7
|
+
* a resolved fix. Learning is expensive and happens later, outside the loop
|
|
8
|
+
* (`rigour learn --agent-fixes`), with the same validation as any learned rule.
|
|
9
|
+
*
|
|
10
|
+
* A finding only counts as resolved when its file was part of the review that
|
|
11
|
+
* no longer reports it: a file committed out of the working tree was not fixed.
|
|
12
|
+
*/
|
|
13
|
+
import crypto from 'crypto';
|
|
14
|
+
import fs from 'fs';
|
|
15
|
+
import path from 'path';
|
|
16
|
+
const DIR = path.join('.rigour', 'agent-fixes');
|
|
17
|
+
const MAX_FILE_BYTES = 200_000;
|
|
18
|
+
const OPEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
19
|
+
/** Record a review's findings: open new ones, resolve open ones the review no longer reports. */
|
|
20
|
+
export function recordReviewOutcome(cwd, findings, reviewedFiles) {
|
|
21
|
+
const open = readOpen(cwd);
|
|
22
|
+
const current = new Map(findings.flatMap(f => (f.files?.[0] ? [[`${f.id}:${f.files[0]}`, f]] : [])));
|
|
23
|
+
const capture = { resolved: resolveGone(cwd, open, current, new Set(reviewedFiles)), opened: openNew(cwd, open, current) };
|
|
24
|
+
writeOpen(cwd, open);
|
|
25
|
+
return capture;
|
|
26
|
+
}
|
|
27
|
+
/** Open findings this review no longer reports: resolved when their file was reviewed and changed. */
|
|
28
|
+
function resolveGone(cwd, open, current, reviewed) {
|
|
29
|
+
let resolved = 0;
|
|
30
|
+
for (const [key, entry] of Object.entries(open)) {
|
|
31
|
+
if (current.has(key))
|
|
32
|
+
continue;
|
|
33
|
+
if (!reviewed.has(entry.file)) {
|
|
34
|
+
if (Date.now() - Date.parse(entry.openedAt) > OPEN_TTL_MS)
|
|
35
|
+
delete open[key];
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
const after = readSmall(cwd, entry.file);
|
|
39
|
+
if (after !== null && after !== entry.before) {
|
|
40
|
+
writeResolved(cwd, entry, after);
|
|
41
|
+
resolved++;
|
|
42
|
+
}
|
|
43
|
+
delete open[key];
|
|
44
|
+
}
|
|
45
|
+
return resolved;
|
|
46
|
+
}
|
|
47
|
+
function openNew(cwd, open, current) {
|
|
48
|
+
let opened = 0;
|
|
49
|
+
for (const [key, finding] of current) {
|
|
50
|
+
if (open[key])
|
|
51
|
+
continue;
|
|
52
|
+
const file = finding.files[0];
|
|
53
|
+
const before = readSmall(cwd, file);
|
|
54
|
+
if (before === null)
|
|
55
|
+
continue;
|
|
56
|
+
open[key] = { file, rule: finding.id, before, openedAt: new Date().toISOString() };
|
|
57
|
+
opened++;
|
|
58
|
+
}
|
|
59
|
+
return opened;
|
|
60
|
+
}
|
|
61
|
+
export function listResolvedFixes(cwd) {
|
|
62
|
+
const dir = path.join(cwd, DIR, 'resolved');
|
|
63
|
+
if (!fs.existsSync(dir))
|
|
64
|
+
return [];
|
|
65
|
+
return fs.readdirSync(dir).filter(n => n.endsWith('.json')).sort().flatMap(name => {
|
|
66
|
+
try {
|
|
67
|
+
return [JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8'))];
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
export function removeResolvedFix(cwd, id) {
|
|
75
|
+
fs.rmSync(path.join(cwd, DIR, 'resolved', `${id}.json`), { force: true });
|
|
76
|
+
}
|
|
77
|
+
export function openFindingCount(cwd) {
|
|
78
|
+
return Object.keys(readOpen(cwd)).length;
|
|
79
|
+
}
|
|
80
|
+
function readOpen(cwd) {
|
|
81
|
+
try {
|
|
82
|
+
return JSON.parse(fs.readFileSync(path.join(cwd, DIR, 'open.json'), 'utf8'));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return {};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function writeOpen(cwd, open) {
|
|
89
|
+
try {
|
|
90
|
+
fs.mkdirSync(path.join(cwd, DIR), { recursive: true });
|
|
91
|
+
fs.writeFileSync(path.join(cwd, DIR, 'open.json'), JSON.stringify(open));
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Capture is best-effort; it must never break a review.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function writeResolved(cwd, entry, after) {
|
|
98
|
+
const id = crypto.createHash('sha256').update(`${entry.rule}\0${entry.file}\0${entry.before}\0${after}`).digest('hex').slice(0, 16);
|
|
99
|
+
const fix = { id, file: entry.file, rule: entry.rule, before: entry.before, after, resolvedAt: new Date().toISOString() };
|
|
100
|
+
try {
|
|
101
|
+
fs.mkdirSync(path.join(cwd, DIR, 'resolved'), { recursive: true });
|
|
102
|
+
fs.writeFileSync(path.join(cwd, DIR, 'resolved', `${id}.json`), JSON.stringify(fix));
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Best-effort, as above.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function readSmall(cwd, file) {
|
|
109
|
+
try {
|
|
110
|
+
const full = path.join(cwd, file);
|
|
111
|
+
if (fs.statSync(full).size > MAX_FILE_BYTES)
|
|
112
|
+
return null;
|
|
113
|
+
return fs.readFileSync(full, 'utf8');
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
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 { listResolvedFixes, openFindingCount, recordReviewOutcome } from './agent-fixes.js';
|
|
6
|
+
const finding = (file) => ({ id: 'semantic-bugs', title: 't', details: 'd', files: [file], line: 2 });
|
|
7
|
+
describe('recordReviewOutcome', () => {
|
|
8
|
+
let cwd;
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-fixes-'));
|
|
11
|
+
fs.writeFileSync(path.join(cwd, 'a.ts'), 'before\n');
|
|
12
|
+
});
|
|
13
|
+
afterEach(() => { fs.rmSync(cwd, { recursive: true, force: true }); });
|
|
14
|
+
it('stores the before and after once a reviewed file no longer has the finding', () => {
|
|
15
|
+
expect(recordReviewOutcome(cwd, [finding('a.ts')], ['a.ts'])).toEqual({ opened: 1, resolved: 0 });
|
|
16
|
+
fs.writeFileSync(path.join(cwd, 'a.ts'), 'after\n');
|
|
17
|
+
expect(recordReviewOutcome(cwd, [], ['a.ts'])).toEqual({ opened: 0, resolved: 1 });
|
|
18
|
+
const [fix] = listResolvedFixes(cwd);
|
|
19
|
+
expect(fix).toMatchObject({ file: 'a.ts', rule: 'semantic-bugs', before: 'before\n', after: 'after\n' });
|
|
20
|
+
expect(openFindingCount(cwd)).toBe(0);
|
|
21
|
+
});
|
|
22
|
+
it('keeps a finding open when its file was not part of the review (committed, not fixed)', () => {
|
|
23
|
+
recordReviewOutcome(cwd, [finding('a.ts')], ['a.ts']);
|
|
24
|
+
expect(recordReviewOutcome(cwd, [], ['other.ts'])).toEqual({ opened: 0, resolved: 0 });
|
|
25
|
+
expect(openFindingCount(cwd)).toBe(1);
|
|
26
|
+
});
|
|
27
|
+
it('does not count a finding that vanished without an edit as a fix', () => {
|
|
28
|
+
recordReviewOutcome(cwd, [finding('a.ts')], ['a.ts']);
|
|
29
|
+
expect(recordReviewOutcome(cwd, [], ['a.ts'])).toEqual({ opened: 0, resolved: 0 });
|
|
30
|
+
expect(listResolvedFixes(cwd)).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which findings belong to a change.
|
|
3
|
+
*
|
|
4
|
+
* - findings: on a changed line; these decide PASS/FAIL.
|
|
5
|
+
* - fileFindings: about a changed file as a whole (no line); shown as context.
|
|
6
|
+
* - unlocated: no file at all; counted, never shown as a changed-line finding.
|
|
7
|
+
* - outside: on an unchanged line; counted.
|
|
8
|
+
*
|
|
9
|
+
* The CLI, the MCP tool and the GitHub integration all use this, so the same
|
|
10
|
+
* change gets the same verdict everywhere.
|
|
11
|
+
*/
|
|
12
|
+
import type { Failure } from '../types/index.js';
|
|
13
|
+
export interface ChangedLineSplit {
|
|
14
|
+
findings: Failure[];
|
|
15
|
+
fileFindings: Failure[];
|
|
16
|
+
unlocated: number;
|
|
17
|
+
outside: number;
|
|
18
|
+
}
|
|
19
|
+
export declare function splitByChangedLines(failures: Failure[], changedLines: Record<string, Set<number>>): ChangedLineSplit;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export function splitByChangedLines(failures, changedLines) {
|
|
2
|
+
const split = { findings: [], fileFindings: [], unlocated: 0, outside: 0 };
|
|
3
|
+
for (const failure of failures) {
|
|
4
|
+
const files = failure.files ?? [];
|
|
5
|
+
if (files.length === 0) {
|
|
6
|
+
split.unlocated++;
|
|
7
|
+
}
|
|
8
|
+
else if (failure.line === undefined) {
|
|
9
|
+
if (files.some(file => changedLines[file]))
|
|
10
|
+
split.fileFindings.push(failure);
|
|
11
|
+
else
|
|
12
|
+
split.outside++;
|
|
13
|
+
}
|
|
14
|
+
else if (files.some(file => changedLines[file]?.has(failure.line))) {
|
|
15
|
+
split.findings.push(failure);
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
split.outside++;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return split;
|
|
22
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface AgentEvent {
|
|
2
|
+
type: string;
|
|
3
|
+
timestamp?: string;
|
|
4
|
+
tool?: string;
|
|
5
|
+
status?: string;
|
|
6
|
+
content?: Array<{
|
|
7
|
+
type: string;
|
|
8
|
+
text?: string;
|
|
9
|
+
}>;
|
|
10
|
+
session?: string;
|
|
11
|
+
blocked?: boolean;
|
|
12
|
+
/** Findings that blocked a stop. */
|
|
13
|
+
blocking?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ReviewEffectiveness {
|
|
16
|
+
toolCalls: Record<string, number>;
|
|
17
|
+
reviews: number;
|
|
18
|
+
failedReviews: number;
|
|
19
|
+
findingsReported: number;
|
|
20
|
+
findingsResolved: number;
|
|
21
|
+
stopChecks: number;
|
|
22
|
+
stopBlocks: number;
|
|
23
|
+
blockedStopsFollowedThrough: number;
|
|
24
|
+
stopsWithSelfReview: number;
|
|
25
|
+
}
|
|
26
|
+
export declare function readAgentEvents(cwd: string): AgentEvent[];
|
|
27
|
+
/** Append an event in the same format the MCP server writes; never throws. */
|
|
28
|
+
export declare function appendAgentEvent(cwd: string, event: AgentEvent): void;
|
|
29
|
+
/** `rule:file` keys, so a finding that moved lines is still the same finding. */
|
|
30
|
+
export declare function findingKeys(findings: Array<{
|
|
31
|
+
id?: string;
|
|
32
|
+
file?: string;
|
|
33
|
+
}>): string[];
|
|
34
|
+
export declare function computeEffectiveness(events: AgentEvent[]): ReviewEffectiveness;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Is the review loop working? Computed from the local event log
|
|
3
|
+
* (`.rigour/events.jsonl`, written by the MCP server and the stop hook), so
|
|
4
|
+
* nothing leaves the machine.
|
|
5
|
+
*
|
|
6
|
+
* - a finding is resolved when a later review no longer reports it (same rule, same file);
|
|
7
|
+
* - a blocked stop is followed through when the same session later stops cleanly;
|
|
8
|
+
* - a self-review is a rigour_review call made before an attempt to stop.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { randomUUID } from 'crypto';
|
|
13
|
+
const EVENTS = path.join('.rigour', 'events.jsonl');
|
|
14
|
+
export function readAgentEvents(cwd) {
|
|
15
|
+
let text = '';
|
|
16
|
+
try {
|
|
17
|
+
text = fs.readFileSync(path.join(cwd, EVENTS), 'utf8');
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
return text.split('\n').filter(Boolean).flatMap(line => {
|
|
23
|
+
try {
|
|
24
|
+
return [JSON.parse(line)];
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** Append an event in the same format the MCP server writes; never throws. */
|
|
32
|
+
export function appendAgentEvent(cwd, event) {
|
|
33
|
+
try {
|
|
34
|
+
fs.mkdirSync(path.join(cwd, '.rigour'), { recursive: true });
|
|
35
|
+
fs.appendFileSync(path.join(cwd, EVENTS), JSON.stringify({ id: randomUUID(), timestamp: new Date().toISOString(), ...event }) + '\n');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// The event log is best-effort; it must never break a hook or a tool call.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** `rule:file` keys, so a finding that moved lines is still the same finding. */
|
|
42
|
+
export function findingKeys(findings) {
|
|
43
|
+
return findings.map(f => `${f.id ?? '?'}:${f.file ?? '?'}`);
|
|
44
|
+
}
|
|
45
|
+
export function computeEffectiveness(events) {
|
|
46
|
+
const stats = {
|
|
47
|
+
toolCalls: {}, reviews: 0, failedReviews: 0, findingsReported: 0, findingsResolved: 0,
|
|
48
|
+
stopChecks: 0, stopBlocks: 0, blockedStopsFollowedThrough: 0, stopsWithSelfReview: 0,
|
|
49
|
+
};
|
|
50
|
+
const open = new Set();
|
|
51
|
+
const blockedSessions = new Set();
|
|
52
|
+
let reviewedSinceStop = false;
|
|
53
|
+
for (const event of events) {
|
|
54
|
+
if (event.type === 'tool_call' && event.tool) {
|
|
55
|
+
stats.toolCalls[event.tool] = (stats.toolCalls[event.tool] ?? 0) + 1;
|
|
56
|
+
if (event.tool === 'rigour_review')
|
|
57
|
+
reviewedSinceStop = true;
|
|
58
|
+
}
|
|
59
|
+
else if (event.type === 'tool_response' && event.tool === 'rigour_review') {
|
|
60
|
+
countReview(stats, open, reviewKeys(event));
|
|
61
|
+
}
|
|
62
|
+
else if (event.type === 'stop_review') {
|
|
63
|
+
countStop(stats, blockedSessions, event, reviewedSinceStop);
|
|
64
|
+
reviewedSinceStop = false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return stats;
|
|
68
|
+
}
|
|
69
|
+
function countReview(stats, open, keys) {
|
|
70
|
+
if (keys === null)
|
|
71
|
+
return;
|
|
72
|
+
stats.reviews++;
|
|
73
|
+
if (keys.length)
|
|
74
|
+
stats.failedReviews++;
|
|
75
|
+
const now = new Set(keys);
|
|
76
|
+
for (const key of [...open]) {
|
|
77
|
+
if (!now.has(key)) {
|
|
78
|
+
open.delete(key);
|
|
79
|
+
stats.findingsResolved++;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
for (const key of now) {
|
|
83
|
+
if (!open.has(key)) {
|
|
84
|
+
open.add(key);
|
|
85
|
+
stats.findingsReported++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function countStop(stats, blockedSessions, event, selfReviewed) {
|
|
90
|
+
const session = event.session ?? 'default';
|
|
91
|
+
stats.stopChecks++;
|
|
92
|
+
if (selfReviewed)
|
|
93
|
+
stats.stopsWithSelfReview++;
|
|
94
|
+
if (event.blocked) {
|
|
95
|
+
stats.stopBlocks++;
|
|
96
|
+
blockedSessions.add(session);
|
|
97
|
+
}
|
|
98
|
+
else if (blockedSessions.delete(session)) {
|
|
99
|
+
stats.blockedStopsFollowedThrough++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Finding keys from a rigour_review response, or null when it is not a review result. */
|
|
103
|
+
function reviewKeys(event) {
|
|
104
|
+
const text = event.content?.find(c => c.type === 'text')?.text;
|
|
105
|
+
if (!text)
|
|
106
|
+
return null;
|
|
107
|
+
try {
|
|
108
|
+
const body = JSON.parse(text);
|
|
109
|
+
return Array.isArray(body.failures) ? findingKeys(body.failures) : null;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { appendAgentEvent, computeEffectiveness, readAgentEvents } from './effectiveness.js';
|
|
6
|
+
const review = (failures) => [
|
|
7
|
+
{ type: 'tool_call', tool: 'rigour_review' },
|
|
8
|
+
{ type: 'tool_response', tool: 'rigour_review', content: [{ type: 'text', text: JSON.stringify({ status: failures.length ? 'FAIL' : 'PASS', failures }) }] },
|
|
9
|
+
];
|
|
10
|
+
describe('computeEffectiveness', () => {
|
|
11
|
+
it('counts reviews, findings resolved by later reviews, and tool calls', () => {
|
|
12
|
+
const events = [
|
|
13
|
+
{ type: 'tool_call', tool: 'rigour_recall' },
|
|
14
|
+
...review([{ id: 'semantic-bugs', file: 'a.ts' }, { id: 'promise-safety', file: 'b.ts' }]),
|
|
15
|
+
...review([{ id: 'promise-safety', file: 'b.ts' }]),
|
|
16
|
+
...review([]),
|
|
17
|
+
];
|
|
18
|
+
expect(computeEffectiveness(events)).toMatchObject({
|
|
19
|
+
reviews: 3, failedReviews: 2, findingsReported: 2, findingsResolved: 2,
|
|
20
|
+
toolCalls: { rigour_recall: 1, rigour_review: 3 },
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
it('follows a blocked stop through to a clean stop in the same session, and counts self-reviews', () => {
|
|
24
|
+
const events = [
|
|
25
|
+
{ type: 'stop_review', session: 's1', blocked: true },
|
|
26
|
+
...review([]),
|
|
27
|
+
{ type: 'stop_review', session: 's1', blocked: false },
|
|
28
|
+
{ type: 'stop_review', session: 's2', blocked: false },
|
|
29
|
+
];
|
|
30
|
+
expect(computeEffectiveness(events)).toMatchObject({
|
|
31
|
+
stopChecks: 3, stopBlocks: 1, blockedStopsFollowedThrough: 1, stopsWithSelfReview: 1,
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
it('reads the log it writes and skips malformed lines', () => {
|
|
35
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'effectiveness-'));
|
|
36
|
+
appendAgentEvent(dir, { type: 'stop_review', blocked: true });
|
|
37
|
+
fs.appendFileSync(path.join(dir, '.rigour', 'events.jsonl'), 'not json\n');
|
|
38
|
+
expect(readAgentEvents(dir).map(e => e.type)).toEqual(['stop_review']);
|
|
39
|
+
expect(readAgentEvents(path.join(dir, 'missing'))).toEqual([]);
|
|
40
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diff to review, taken from git so callers do not have to build one.
|
|
3
|
+
*
|
|
4
|
+
* - working: uncommitted changes against HEAD, staged or not, plus new files
|
|
5
|
+
* git does not track yet (what an agent has just written).
|
|
6
|
+
* - base: everything on this branch since it left `base` (the merge-base),
|
|
7
|
+
* including uncommitted changes (what a pull request contains).
|
|
8
|
+
*/
|
|
9
|
+
import { spawnSync } from 'child_process';
|
|
10
|
+
export class GitDiffError extends Error {
|
|
11
|
+
}
|
|
12
|
+
export function diffFromGit(cwd, source = { mode: 'working' }) {
|
|
13
|
+
if (source.mode === 'base') {
|
|
14
|
+
const mergeBase = git(cwd, ['merge-base', source.base, 'HEAD']).trim();
|
|
15
|
+
return git(cwd, ['diff', '--no-color', '--no-ext-diff', mergeBase]);
|
|
16
|
+
}
|
|
17
|
+
const tracked = hasHead(cwd) ? git(cwd, ['diff', '--no-color', '--no-ext-diff', 'HEAD']) : '';
|
|
18
|
+
return tracked + untrackedFiles(cwd).map(file => newFileDiff(cwd, file)).join('');
|
|
19
|
+
}
|
|
20
|
+
function untrackedFiles(cwd) {
|
|
21
|
+
return git(cwd, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean);
|
|
22
|
+
}
|
|
23
|
+
/** `git diff --no-index` exits 1 when the files differ, which for a new file is always. */
|
|
24
|
+
function newFileDiff(cwd, file) {
|
|
25
|
+
const result = spawnSync('git', ['diff', '--no-color', '--no-ext-diff', '--no-index', '--', '/dev/null', file], {
|
|
26
|
+
cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
|
27
|
+
});
|
|
28
|
+
if (result.status !== 0 && result.status !== 1)
|
|
29
|
+
throw new GitDiffError(`git diff for new file ${file} failed: ${result.stderr.trim()}`);
|
|
30
|
+
return result.stdout;
|
|
31
|
+
}
|
|
32
|
+
function hasHead(cwd) {
|
|
33
|
+
return spawnSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], { cwd }).status === 0;
|
|
34
|
+
}
|
|
35
|
+
function git(cwd, args) {
|
|
36
|
+
const result = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
37
|
+
if (result.error)
|
|
38
|
+
throw new GitDiffError(`git is not available: ${result.error.message}`);
|
|
39
|
+
if (result.status !== 0)
|
|
40
|
+
throw new GitDiffError(`git ${args.join(' ')} failed: ${result.stderr.trim()}`);
|
|
41
|
+
return result.stdout;
|
|
42
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Config, DeepOptions, Failure, Report } from '../types/index.js';
|
|
2
|
+
import { type DiffSource } from './git-diff.js';
|
|
3
|
+
export interface ReviewInput {
|
|
4
|
+
cwd: string;
|
|
5
|
+
config: Config;
|
|
6
|
+
/** A unified diff; when omitted, it is taken from git (`source`). */
|
|
7
|
+
diff?: string;
|
|
8
|
+
source?: DiffSource;
|
|
9
|
+
/** Review exactly these files instead of the ones the diff touches. */
|
|
10
|
+
files?: string[];
|
|
11
|
+
/** Deep analysis; `focusLines` is filled from the diff. */
|
|
12
|
+
deep?: Omit<DeepOptions, 'focusLines'>;
|
|
13
|
+
}
|
|
14
|
+
export interface ReviewResult {
|
|
15
|
+
status: 'PASS' | 'FAIL' | 'ERROR';
|
|
16
|
+
findings: Failure[];
|
|
17
|
+
fileFindings: Failure[];
|
|
18
|
+
unlocated: number;
|
|
19
|
+
excludedOutsideChangedLines: number;
|
|
20
|
+
changedLines: Record<string, Set<number>>;
|
|
21
|
+
report: Report | null;
|
|
22
|
+
deepError?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface ReviewFinding {
|
|
25
|
+
id: string;
|
|
26
|
+
gate: string;
|
|
27
|
+
severity: string;
|
|
28
|
+
provenance: string;
|
|
29
|
+
message: string;
|
|
30
|
+
file: string;
|
|
31
|
+
line: number | null;
|
|
32
|
+
suggestion?: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function reviewChange(input: ReviewInput): Promise<ReviewResult>;
|
|
35
|
+
/** The JSON shape of a finding, shared by the CLI's --json and the MCP tool. */
|
|
36
|
+
export declare function toReviewFinding(failure: Failure): ReviewFinding;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* reviewChange: the one review engine behind `rigour review`, the MCP
|
|
3
|
+
* `rigour_review` tool and the GitHub integration.
|
|
4
|
+
*
|
|
5
|
+
* Runs the configured gates on the files a change touches and keeps what
|
|
6
|
+
* belongs to the change (see changed-lines.ts). A deep run that was asked for
|
|
7
|
+
* but could not analyze anything makes the result ERROR, never a clean PASS.
|
|
8
|
+
*/
|
|
9
|
+
import { GateRunner } from '../gates/runner.js';
|
|
10
|
+
import { changedLinesByFile, parseDiff } from '../utils/diff.js';
|
|
11
|
+
import { normalizeScopePatterns } from '../utils/scope.js';
|
|
12
|
+
import { deepAnalysisError } from '../utils/deep-status.js';
|
|
13
|
+
import { splitByChangedLines } from './changed-lines.js';
|
|
14
|
+
import { diffFromGit } from './git-diff.js';
|
|
15
|
+
export async function reviewChange(input) {
|
|
16
|
+
const diff = input.diff ?? diffFromGit(input.cwd, input.source);
|
|
17
|
+
const changedLines = parseDiff(diff);
|
|
18
|
+
const targets = input.files?.length ? input.files : Object.keys(changedLines);
|
|
19
|
+
if (targets.length === 0) {
|
|
20
|
+
return { status: 'PASS', findings: [], fileFindings: [], unlocated: 0, excludedOutsideChangedLines: 0, changedLines, report: null };
|
|
21
|
+
}
|
|
22
|
+
const deep = input.deep ? { ...input.deep, focusLines: changedLinesByFile(changedLines) } : undefined;
|
|
23
|
+
const report = await new GateRunner(input.config).run(input.cwd, await normalizeScopePatterns(input.cwd, targets), deep);
|
|
24
|
+
const split = splitByChangedLines(report.failures, changedLines);
|
|
25
|
+
const deepError = deepAnalysisError(report);
|
|
26
|
+
return {
|
|
27
|
+
status: deepError ? 'ERROR' : split.findings.length > 0 ? 'FAIL' : 'PASS',
|
|
28
|
+
findings: split.findings,
|
|
29
|
+
fileFindings: split.fileFindings,
|
|
30
|
+
unlocated: split.unlocated,
|
|
31
|
+
excludedOutsideChangedLines: split.outside,
|
|
32
|
+
changedLines,
|
|
33
|
+
report,
|
|
34
|
+
...(deepError ? { deepError } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/** The JSON shape of a finding, shared by the CLI's --json and the MCP tool. */
|
|
38
|
+
export function toReviewFinding(failure) {
|
|
39
|
+
return {
|
|
40
|
+
id: failure.id,
|
|
41
|
+
gate: failure.title,
|
|
42
|
+
severity: failure.severity || 'medium',
|
|
43
|
+
provenance: failure.provenance || 'traditional',
|
|
44
|
+
message: failure.details,
|
|
45
|
+
file: failure.files?.[0] || '',
|
|
46
|
+
line: failure.line ?? null,
|
|
47
|
+
...(failure.hint ? { suggestion: failure.hint } : {}),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
|
+
import { ConfigSchema } from '../types/index.js';
|
|
7
|
+
import { parseDiff } from '../utils/diff.js';
|
|
8
|
+
import { splitByChangedLines } from './changed-lines.js';
|
|
9
|
+
import { diffFromGit } from './git-diff.js';
|
|
10
|
+
import { reviewChange, toReviewFinding } from './review.js';
|
|
11
|
+
const LEAKY = [
|
|
12
|
+
'export async function notify(endpoint: string, signature: string) {',
|
|
13
|
+
" return fetch(endpoint, { method: 'POST', headers: { 'x-hook-signature': signature } });",
|
|
14
|
+
'}',
|
|
15
|
+
'',
|
|
16
|
+
].join('\n');
|
|
17
|
+
function failure(file, line) {
|
|
18
|
+
return { id: 'x', title: 'X', details: 'd', files: file ? [file] : [], ...(line !== undefined ? { line } : {}) };
|
|
19
|
+
}
|
|
20
|
+
describe('splitByChangedLines', () => {
|
|
21
|
+
it('separates changed-line, file-level, unlocated and outside findings', () => {
|
|
22
|
+
const changed = { 'a.ts': new Set([3]) };
|
|
23
|
+
const split = splitByChangedLines([failure('a.ts', 3), failure('a.ts', 9), failure('a.ts'), failure('b.ts'), failure(undefined)], changed);
|
|
24
|
+
expect(split.findings.map(f => f.line)).toEqual([3]);
|
|
25
|
+
expect(split.fileFindings).toHaveLength(1);
|
|
26
|
+
expect(split.unlocated).toBe(1);
|
|
27
|
+
expect(split.outside).toBe(2);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe('git-backed review', () => {
|
|
31
|
+
let repo;
|
|
32
|
+
const git = (...args) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' });
|
|
33
|
+
const write = (rel, body) => {
|
|
34
|
+
fs.mkdirSync(path.dirname(path.join(repo, rel)), { recursive: true });
|
|
35
|
+
fs.writeFileSync(path.join(repo, rel), body);
|
|
36
|
+
};
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
repo = fs.mkdtempSync(path.join(os.tmpdir(), 'review-core-'));
|
|
39
|
+
git('init', '-q', '-b', 'main');
|
|
40
|
+
git('config', 'user.email', 't@example.com');
|
|
41
|
+
git('config', 'user.name', 't');
|
|
42
|
+
git('config', 'commit.gpgsign', 'false');
|
|
43
|
+
});
|
|
44
|
+
afterEach(() => { fs.rmSync(repo, { recursive: true, force: true }); });
|
|
45
|
+
it('works in a repository with no commits yet', () => {
|
|
46
|
+
write('src/new.ts', 'export const a = 1;\n');
|
|
47
|
+
expect(parseDiff(diffFromGit(repo))).toEqual({ 'src/new.ts': new Set([1]) });
|
|
48
|
+
});
|
|
49
|
+
it('takes uncommitted edits and untracked files from the working tree', () => {
|
|
50
|
+
write('src/a.ts', 'export const a = 1;\nexport const b = 2;\n');
|
|
51
|
+
git('add', '-A');
|
|
52
|
+
git('commit', '-qm', 'init');
|
|
53
|
+
write('src/a.ts', 'export const a = 1;\nexport const b = 3;\n');
|
|
54
|
+
write('src/new.ts', 'export const c = 1;\nexport const d = 2;\n');
|
|
55
|
+
expect(parseDiff(diffFromGit(repo))).toEqual({ 'src/a.ts': new Set([2]), 'src/new.ts': new Set([1, 2]) });
|
|
56
|
+
});
|
|
57
|
+
it('takes a branch against its merge-base, committed and uncommitted', () => {
|
|
58
|
+
write('src/a.ts', 'export const a = 1;\n');
|
|
59
|
+
git('add', '-A');
|
|
60
|
+
git('commit', '-qm', 'init');
|
|
61
|
+
git('checkout', '-q', '-b', 'feature');
|
|
62
|
+
write('src/b.ts', 'export const b = 1;\n');
|
|
63
|
+
git('add', '-A');
|
|
64
|
+
git('commit', '-qm', 'feature');
|
|
65
|
+
write('src/a.ts', 'export const a = 1;\nexport const z = 2;\n');
|
|
66
|
+
expect(parseDiff(diffFromGit(repo, { mode: 'base', base: 'main' }))).toEqual({ 'src/a.ts': new Set([2]), 'src/b.ts': new Set([1]) });
|
|
67
|
+
});
|
|
68
|
+
it('fails a change that introduces a bug and ignores the same bug in untouched code', async () => {
|
|
69
|
+
write('src/old.ts', LEAKY);
|
|
70
|
+
git('add', '-A');
|
|
71
|
+
git('commit', '-qm', 'init');
|
|
72
|
+
write('src/notify.ts', LEAKY);
|
|
73
|
+
const config = ConfigSchema.parse({ version: 1, gates: { semantic_bugs: { enabled: true } } });
|
|
74
|
+
const result = await reviewChange({ cwd: repo, config });
|
|
75
|
+
const semantic = result.findings.filter(f => f.id === 'semantic-bugs');
|
|
76
|
+
expect(result.status).toBe('FAIL');
|
|
77
|
+
expect(semantic.map(f => [f.files?.[0], f.line])).toEqual([['src/notify.ts', 2]]);
|
|
78
|
+
expect(toReviewFinding(semantic[0])).toMatchObject({ id: 'semantic-bugs', file: 'src/notify.ts', line: 2, severity: 'high' });
|
|
79
|
+
});
|
|
80
|
+
it('passes when nothing changed', async () => {
|
|
81
|
+
write('src/a.ts', 'export const a = 1;\n');
|
|
82
|
+
git('add', '-A');
|
|
83
|
+
git('commit', '-qm', 'init');
|
|
84
|
+
const result = await reviewChange({ cwd: repo, config: ConfigSchema.parse({ version: 1 }) });
|
|
85
|
+
expect(result).toMatchObject({ status: 'PASS', findings: [], report: null });
|
|
86
|
+
});
|
|
87
|
+
});
|
|
@@ -23,3 +23,5 @@ export interface LearnReport {
|
|
|
23
23
|
}>;
|
|
24
24
|
}
|
|
25
25
|
export declare function learnFromFix(input: LearnInput): Promise<LearnReport>;
|
|
26
|
+
/** Learn from one file's before and after (e.g. an agent's fix), validated against the repository at `repoDir`. */
|
|
27
|
+
export declare function learnFromFileChange(repoDir: string, file: string, before: string, after: string, maxHits?: number): Promise<LearnReport>;
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* reported with the reason the most specific one failed.
|
|
9
9
|
*/
|
|
10
10
|
import crypto from 'crypto';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import os from 'os';
|
|
11
13
|
import path from 'path';
|
|
12
14
|
import { globby } from 'globby';
|
|
13
15
|
import { analyzeFiles } from '../engine.js';
|
|
@@ -47,6 +49,22 @@ export async function learnFromFix(input) {
|
|
|
47
49
|
}
|
|
48
50
|
return report;
|
|
49
51
|
}
|
|
52
|
+
/** Learn from one file's before and after (e.g. an agent's fix), validated against the repository at `repoDir`. */
|
|
53
|
+
export async function learnFromFileChange(repoDir, file, before, after, maxHits) {
|
|
54
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'rigour-learn-'));
|
|
55
|
+
try {
|
|
56
|
+
const place = (tree, content) => {
|
|
57
|
+
const target = path.join(root, tree, file);
|
|
58
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
59
|
+
fs.writeFileSync(target, content);
|
|
60
|
+
return path.join(root, tree);
|
|
61
|
+
};
|
|
62
|
+
return await learnFromFix({ beforeDir: place('before', before), afterDir: place('after', after), repoDir, files: [file], maxHits });
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
}
|
|
50
68
|
function openTree(cwd, file) {
|
|
51
69
|
const root = path.join(cwd, file);
|
|
52
70
|
const [program] = [...programBatches([root], loadProjectConfig(cwd).options)];
|
package/dist/types/index.js
CHANGED
|
@@ -266,8 +266,9 @@ export const GatesSchema = z.object({
|
|
|
266
266
|
audit_log: z.boolean().optional().default(true),
|
|
267
267
|
}).optional().default({}),
|
|
268
268
|
// v4.3+ Side-Effect Safety Analysis
|
|
269
|
+
// On by default: full scans of three real repositories reported no finding that was not a real defect.
|
|
269
270
|
semantic_bugs: z.object({
|
|
270
|
-
enabled: z.boolean().optional().default(
|
|
271
|
+
enabled: z.boolean().optional().default(true),
|
|
271
272
|
rules: z.array(z.string()).optional(),
|
|
272
273
|
}).optional().default({}),
|
|
273
274
|
side_effect_analysis: z.object({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rigour-labs/core",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.4.0",
|
|
4
4
|
"description": "AI-native quality gate engine with local Bayesian learning. AST analysis, drift detection, Fix Packet generation, and agent self-healing across TypeScript, JavaScript, Python, Go, Ruby, and C#.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://rigour.run",
|
|
@@ -67,11 +67,11 @@
|
|
|
67
67
|
"pg": "^8.16.3",
|
|
68
68
|
"sqlite3": "^5.1.7",
|
|
69
69
|
"openai": "^4.104.0",
|
|
70
|
-
"@rigour-labs/brain-darwin-arm64": "6.
|
|
71
|
-
"@rigour-labs/brain-
|
|
72
|
-
"@rigour-labs/brain-
|
|
73
|
-
"@rigour-labs/brain-
|
|
74
|
-
"@rigour-labs/brain-
|
|
70
|
+
"@rigour-labs/brain-darwin-arm64": "6.4.0",
|
|
71
|
+
"@rigour-labs/brain-linux-arm64": "6.4.0",
|
|
72
|
+
"@rigour-labs/brain-darwin-x64": "6.4.0",
|
|
73
|
+
"@rigour-labs/brain-linux-x64": "6.4.0",
|
|
74
|
+
"@rigour-labs/brain-win-x64": "6.4.0"
|
|
75
75
|
},
|
|
76
76
|
"devDependencies": {
|
|
77
77
|
"@types/fs-extra": "^11.0.4",
|