@rigour-labs/core 6.2.4 → 6.2.5

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.
Files changed (80) hide show
  1. package/dist/deep/code-context.d.ts +28 -0
  2. package/dist/deep/code-context.js +93 -0
  3. package/dist/deep/code-context.test.d.ts +1 -0
  4. package/dist/deep/code-context.test.js +48 -0
  5. package/dist/deep/code-pass.d.ts +15 -0
  6. package/dist/deep/code-pass.js +44 -0
  7. package/dist/deep/code-review-prompt.d.ts +17 -0
  8. package/dist/deep/code-review-prompt.js +30 -0
  9. package/dist/deep/code-review-prompt.test.d.ts +1 -0
  10. package/dist/deep/code-review-prompt.test.js +20 -0
  11. package/dist/deep/code-verifier.d.ts +19 -0
  12. package/dist/deep/code-verifier.js +48 -0
  13. package/dist/deep/code-verifier.test.d.ts +1 -0
  14. package/dist/deep/code-verifier.test.js +46 -0
  15. package/dist/deep/fact-extractor.d.ts +7 -1
  16. package/dist/deep/fact-extractor.js +16 -8
  17. package/dist/deep/fact-extractor.test.js +34 -1
  18. package/dist/deep/facts-pass.d.ts +24 -0
  19. package/dist/deep/facts-pass.js +69 -0
  20. package/dist/deep/index.d.ts +6 -1
  21. package/dist/deep/index.js +5 -1
  22. package/dist/deep/parse-findings.d.ts +12 -0
  23. package/dist/deep/parse-findings.js +73 -0
  24. package/dist/deep/parse-findings.test.d.ts +1 -0
  25. package/dist/deep/parse-findings.test.js +25 -0
  26. package/dist/deep/prompts.d.ts +1 -1
  27. package/dist/deep/prompts.js +7 -8
  28. package/dist/deep/prompts.test.js +15 -0
  29. package/dist/gates/context.js +7 -0
  30. package/dist/gates/deep-analysis.d.ts +31 -8
  31. package/dist/gates/deep-analysis.js +165 -306
  32. package/dist/gates/deep-analysis.test.d.ts +1 -0
  33. package/dist/gates/deep-analysis.test.js +86 -0
  34. package/dist/gates/deep-runner.d.ts +15 -0
  35. package/dist/gates/deep-runner.js +65 -0
  36. package/dist/gates/dependency.js +4 -0
  37. package/dist/gates/logic-drift.js +7 -1
  38. package/dist/gates/runner.js +5 -40
  39. package/dist/gates/runner.test.js +37 -25
  40. package/dist/gates/scoped-gates.test.d.ts +1 -0
  41. package/dist/gates/scoped-gates.test.js +52 -0
  42. package/dist/gates/style-drift.js +3 -0
  43. package/dist/index.d.ts +6 -1
  44. package/dist/index.js +6 -1
  45. package/dist/inference/http-download.d.ts +22 -0
  46. package/dist/inference/http-download.js +132 -0
  47. package/dist/inference/http-download.test.d.ts +1 -0
  48. package/dist/inference/http-download.test.js +109 -0
  49. package/dist/inference/index.d.ts +5 -1
  50. package/dist/inference/index.js +2 -1
  51. package/dist/inference/llama-engine.d.ts +27 -0
  52. package/dist/inference/llama-engine.js +152 -0
  53. package/dist/inference/llama-engine.test.d.ts +1 -0
  54. package/dist/inference/llama-engine.test.js +51 -0
  55. package/dist/inference/llama-process.d.ts +72 -0
  56. package/dist/inference/llama-process.js +126 -0
  57. package/dist/inference/llama-process.test.d.ts +1 -0
  58. package/dist/inference/llama-process.test.js +61 -0
  59. package/dist/inference/model-download.test.d.ts +1 -0
  60. package/dist/inference/model-download.test.js +73 -0
  61. package/dist/inference/model-manager.d.ts +14 -1
  62. package/dist/inference/model-manager.js +65 -80
  63. package/dist/inference/sidecar-provider.d.ts +19 -10
  64. package/dist/inference/sidecar-provider.js +115 -343
  65. package/dist/templates/universal-config.js +0 -2
  66. package/dist/types/index.d.ts +58 -14
  67. package/dist/types/index.js +9 -2
  68. package/dist/utils/deep-status.d.ts +7 -0
  69. package/dist/utils/deep-status.js +11 -0
  70. package/dist/utils/diff.d.ts +3 -0
  71. package/dist/utils/diff.js +52 -0
  72. package/dist/utils/diff.test.d.ts +1 -0
  73. package/dist/utils/diff.test.js +38 -0
  74. package/dist/utils/scanner.js +13 -2
  75. package/dist/utils/scanner.test.js +22 -8
  76. package/dist/utils/scope.d.ts +15 -0
  77. package/dist/utils/scope.js +51 -0
  78. package/dist/utils/scope.test.d.ts +1 -0
  79. package/dist/utils/scope.test.js +38 -0
  80. package/package.json +6 -6
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Deep analysis as the gate runner reports it: summary status, report stats
3
+ * and failures, derived from the deep gate's outcome.
4
+ */
5
+ import { DeepAnalysisGate } from './deep-analysis.js';
6
+ import { Logger } from '../utils/logger.js';
7
+ /**
8
+ * Run the deep gate and turn its outcome into summary, stats and failures.
9
+ * A deep run that could not analyze anything is ERROR with a visible
10
+ * failure, never a clean PASS.
11
+ */
12
+ export async function runDeepAnalysis(config, context, deepOptions) {
13
+ const start = Date.now();
14
+ const deepGate = new DeepAnalysisGate({
15
+ options: deepOptions,
16
+ checks: config.gates.deep?.checks,
17
+ threads: config.gates.deep?.threads,
18
+ maxTokens: config.gates.deep?.max_tokens,
19
+ temperature: config.gates.deep?.temperature,
20
+ timeoutMs: config.gates.deep?.timeout_ms,
21
+ onProgress: deepOptions.onProgress,
22
+ });
23
+ let findings = [];
24
+ let thrown;
25
+ try {
26
+ findings = await deepGate.run(context);
27
+ }
28
+ catch (error) {
29
+ thrown = error?.message ?? String(error);
30
+ Logger.error(`Deep analysis failed: ${thrown}`);
31
+ }
32
+ // A gate that threw produced nothing: never report its last outcome as ok.
33
+ const outcome = thrown ? { ...deepGate.getOutcome(), status: 'error', error: thrown } : deepGate.getOutcome();
34
+ const isLocal = !deepOptions.apiKey || (deepOptions.provider || '').toLowerCase() === 'local';
35
+ const stats = {
36
+ enabled: true,
37
+ status: outcome.status,
38
+ mode: outcome.mode,
39
+ tier: isLocal ? (deepOptions.pro ? 'deep' : 'lite') : 'cloud',
40
+ model: outcome.model,
41
+ model_fallback: outcome.modelFallback,
42
+ total_ms: Date.now() - start,
43
+ files_analyzed: outcome.filesAnalyzed,
44
+ chunks_total: outcome.chunksTotal,
45
+ chunks_failed: outcome.chunksFailed,
46
+ error: outcome.error,
47
+ findings_count: findings.length,
48
+ findings_verified: findings.filter(f => f.verified).length,
49
+ };
50
+ if (outcome.status === 'error') {
51
+ return {
52
+ failures: [{
53
+ id: 'deep-analysis',
54
+ title: 'Deep analysis did not run',
55
+ details: outcome.error || 'Deep analysis failed before producing results.',
56
+ severity: 'medium',
57
+ provenance: 'traditional',
58
+ hint: 'Run `rigour doctor`. For local mode, `rigour deep pull` installs the engine and model.',
59
+ }],
60
+ summary: 'ERROR',
61
+ stats,
62
+ };
63
+ }
64
+ return { failures: findings, summary: findings.length > 0 ? 'FAIL' : 'PASS', stats };
65
+ }
@@ -18,6 +18,7 @@ import path from 'path';
18
18
  import { Gate } from './base.js';
19
19
  import { FileScanner } from '../utils/scanner.js';
20
20
  import { Logger } from '../utils/logger.js';
21
+ import { scopeExcludes } from '../utils/scope.js';
21
22
  /**
22
23
  * Known heavy packages with lighter alternatives.
23
24
  * Format: package → "alternative (size comparison)"
@@ -84,6 +85,9 @@ export class DependencyGate extends Gate {
84
85
  const depConfig = this.config.gates.dependencies || {};
85
86
  const forbidden = depConfig.forbid || [];
86
87
  const { cwd } = context;
88
+ // Dependency health belongs to the manifest: a scoped run checks it only with package.json in scope.
89
+ if (await scopeExcludes(context, 'package.json'))
90
+ return [];
87
91
  // 1. Scan Node.js (package.json) — forbidden + new checks
88
92
  const pkgPath = path.join(cwd, 'package.json');
89
93
  if (await fs.pathExists(pkgPath)) {
@@ -22,6 +22,7 @@ import { Logger } from '../utils/logger.js';
22
22
  import { languageAdapters } from './language-adapters/index.js';
23
23
  import { extractCallSequence, isDangerousMutation } from './logic-drift-extractors.js';
24
24
  import { isGitWorktree, resolveGitLogicBase } from './logic-drift-git-base.js';
25
+ import { isScoped } from '../utils/scope.js';
25
26
  import fs from 'fs-extra';
26
27
  import path from 'path';
27
28
  import crypto from 'crypto';
@@ -94,6 +95,11 @@ export class LogicDriftGate extends Gate {
94
95
  Logger.debug('Failed to load logic baseline');
95
96
  }
96
97
  }
98
+ // A scoped run sees only some files: compare against the baseline but
99
+ // never create or overwrite it, or the rest of the repo drops out of it.
100
+ const scoped = isScoped(context.patterns);
101
+ if (!previousBaseline && scoped)
102
+ return [];
97
103
  if (!previousBaseline) {
98
104
  // First scan: save baseline, no comparisons yet
99
105
  const baseline = {
@@ -151,7 +157,7 @@ export class LogicDriftGate extends Gate {
151
157
  lastUpdated: new Date().toISOString(),
152
158
  scanCount: previousBaseline.scanCount + 1,
153
159
  };
154
- if (!gitBase)
160
+ if (!gitBase && !scoped)
155
161
  await fs.writeJson(baselinePath, updatedBaseline, { spaces: 2 });
156
162
  if (failures.length > 0) {
157
163
  Logger.info(`Logic Drift: Found ${failures.length} logic mutations`);
@@ -1,5 +1,5 @@
1
1
  import { SEVERITY_WEIGHTS } from '../types/index.js';
2
- import { DeepAnalysisGate } from './deep-analysis.js';
2
+ import { runDeepAnalysis } from './deep-runner.js';
3
3
  import { persistAndReinforce } from '../storage/local-memory.js';
4
4
  import { recordGateRun } from '../services/adaptive-thresholds.js';
5
5
  import { FileGate } from './file.js';
@@ -200,45 +200,10 @@ export class GateRunner {
200
200
  // 3. Run Deep Analysis (if enabled)
201
201
  let deepStats = undefined;
202
202
  if (deepOptions?.enabled) {
203
- const deepSetupStart = Date.now();
204
- const deepGate = new DeepAnalysisGate({
205
- options: deepOptions,
206
- checks: this.config.gates.deep?.checks,
207
- threads: this.config.gates.deep?.threads,
208
- maxTokens: this.config.gates.deep?.max_tokens,
209
- temperature: this.config.gates.deep?.temperature,
210
- timeoutMs: this.config.gates.deep?.timeout_ms,
211
- onProgress: deepOptions.onProgress,
212
- });
213
- try {
214
- const deepFailures = await deepGate.run({ cwd, ignore, patterns });
215
- if (deepFailures.length > 0) {
216
- failures.push(...deepFailures);
217
- summary['deep-analysis'] = 'FAIL';
218
- }
219
- else {
220
- summary['deep-analysis'] = 'PASS';
221
- }
222
- const isLocalDeepExecution = !deepOptions.apiKey || (deepOptions.provider || '').toLowerCase() === 'local';
223
- const deepTier = isLocalDeepExecution
224
- ? (deepOptions.pro ? 'deep' : 'lite')
225
- : 'cloud';
226
- deepStats = {
227
- enabled: true,
228
- tier: deepTier,
229
- model: isLocalDeepExecution
230
- ? (deepOptions.pro ? 'Qwen2.5-Coder-1.5B' : 'Qwen2.5-Coder-0.5B')
231
- : (deepOptions.modelName || deepOptions.provider || 'cloud'),
232
- total_ms: Date.now() - deepSetupStart,
233
- findings_count: deepFailures.length,
234
- findings_verified: deepFailures.filter((f) => f.verified).length,
235
- };
236
- }
237
- catch (error) {
238
- Logger.error(`Deep analysis failed: ${error.message}`);
239
- summary['deep-analysis'] = 'ERROR';
240
- deepStats = { enabled: true };
241
- }
203
+ const deep = await runDeepAnalysis(this.config, { cwd, ignore, patterns }, deepOptions);
204
+ failures.push(...deep.failures);
205
+ summary['deep-analysis'] = deep.summary;
206
+ deepStats = deep.stats;
242
207
  }
243
208
  const status = failures.length > 0 ? 'FAIL' : 'PASS';
244
209
  // Severity-weighted scoring with per-gate cap and deduplication
@@ -4,6 +4,12 @@ import path from 'path';
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
5
  import { GateRunner } from './runner.js';
6
6
  import { DeepAnalysisGate } from './deep-analysis.js';
7
+ function mockDeep(outcome, failures = []) {
8
+ vi.spyOn(DeepAnalysisGate.prototype, 'run').mockResolvedValue(failures);
9
+ vi.spyOn(DeepAnalysisGate.prototype, 'getOutcome').mockReturnValue({
10
+ status: 'ok', mode: 'facts', filesAnalyzed: 1, chunksTotal: 1, chunksFailed: 0, ...outcome,
11
+ });
12
+ }
7
13
  describe('GateRunner deep stats execution mode', () => {
8
14
  let testDir;
9
15
  beforeEach(async () => {
@@ -25,41 +31,47 @@ describe('GateRunner deep stats execution mode', () => {
25
31
  },
26
32
  });
27
33
  }
28
- it('reports local lite tier when provider=local even with apiKey', async () => {
29
- vi.spyOn(DeepAnalysisGate.prototype, 'run').mockResolvedValue([]);
30
- const runner = createRunner();
31
- const report = await runner.run(testDir, undefined, {
32
- enabled: true,
33
- apiKey: 'sk-test',
34
- provider: 'local',
35
- pro: false,
34
+ it('reports local lite tier and the model the provider actually loaded', async () => {
35
+ mockDeep({ model: 'Qwen2.5-Coder-0.5B-Instruct (stock)', modelFallback: true });
36
+ const report = await createRunner().run(testDir, undefined, {
37
+ enabled: true, apiKey: 'sk-test', provider: 'local', pro: false,
36
38
  });
37
39
  expect(report.stats.deep?.tier).toBe('lite');
38
- expect(report.stats.deep?.model).toBe('Qwen2.5-Coder-0.5B');
40
+ expect(report.stats.deep?.model).toBe('Qwen2.5-Coder-0.5B-Instruct (stock)');
41
+ expect(report.stats.deep?.model_fallback).toBe(true);
42
+ expect(report.stats.deep?.status).toBe('ok');
43
+ expect(report.summary['deep-analysis']).toBe('PASS');
39
44
  });
40
45
  it('reports local deep tier when provider=local and pro=true', async () => {
41
- vi.spyOn(DeepAnalysisGate.prototype, 'run').mockResolvedValue([]);
42
- const runner = createRunner();
43
- const report = await runner.run(testDir, undefined, {
44
- enabled: true,
45
- apiKey: 'sk-test',
46
- provider: 'local',
47
- pro: true,
46
+ mockDeep({ model: 'Rigour-Deep-v5.0.0 (Qwen2.5-Coder-1.5B fine-tuned)', modelFallback: false });
47
+ const report = await createRunner().run(testDir, undefined, {
48
+ enabled: true, apiKey: 'sk-test', provider: 'local', pro: true,
48
49
  });
49
50
  expect(report.stats.deep?.tier).toBe('deep');
50
- expect(report.stats.deep?.model).toBe('Qwen2.5-Coder-1.5B');
51
+ expect(report.stats.deep?.model).toBe('Rigour-Deep-v5.0.0 (Qwen2.5-Coder-1.5B fine-tuned)');
51
52
  });
52
53
  it('reports cloud tier/model for cloud providers', async () => {
53
- vi.spyOn(DeepAnalysisGate.prototype, 'run').mockResolvedValue([]);
54
- const runner = createRunner();
55
- const report = await runner.run(testDir, undefined, {
56
- enabled: true,
57
- apiKey: 'sk-test',
58
- provider: 'openai',
59
- modelName: 'gpt-4.1-mini',
60
- pro: false,
54
+ mockDeep({ model: 'gpt-4.1-mini' });
55
+ const report = await createRunner().run(testDir, undefined, {
56
+ enabled: true, apiKey: 'sk-test', provider: 'openai', modelName: 'gpt-4.1-mini', pro: false,
61
57
  });
62
58
  expect(report.stats.deep?.tier).toBe('cloud');
63
59
  expect(report.stats.deep?.model).toBe('gpt-4.1-mini');
64
60
  });
61
+ it('reports a deep run that could not analyze anything as ERROR with a visible failure', async () => {
62
+ mockDeep({ status: 'error', chunksTotal: 3, chunksFailed: 3, error: 'All 3 inference call(s) failed.' });
63
+ const report = await createRunner().run(testDir, undefined, { enabled: true, pro: false });
64
+ expect(report.summary['deep-analysis']).toBe('ERROR');
65
+ expect(report.status).toBe('FAIL');
66
+ expect(report.stats.deep).toMatchObject({ status: 'error', chunks_total: 3, chunks_failed: 3, error: 'All 3 inference call(s) failed.' });
67
+ const failure = report.failures.find(f => f.id === 'deep-analysis');
68
+ expect(failure?.title).toBe('Deep analysis did not run');
69
+ expect(failure?.details).toContain('All 3 inference call(s) failed.');
70
+ });
71
+ it('keeps a partial run as PASS/FAIL by findings and records failed chunks', async () => {
72
+ mockDeep({ status: 'partial', chunksTotal: 4, chunksFailed: 1 });
73
+ const report = await createRunner().run(testDir, undefined, { enabled: true, pro: false });
74
+ expect(report.summary['deep-analysis']).toBe('PASS');
75
+ expect(report.stats.deep).toMatchObject({ status: 'partial', chunks_total: 4, chunks_failed: 1 });
76
+ });
65
77
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,52 @@
1
+ import fs from 'fs-extra';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5
+ import { ContextGate } from './context.js';
6
+ import { DependencyGate } from './dependency.js';
7
+ import { LogicDriftGate } from './logic-drift.js';
8
+ import { StyleDriftGate } from './style-drift.js';
9
+ describe('gates on a scoped run', () => {
10
+ let cwd;
11
+ beforeEach(async () => { cwd = await fs.mkdtemp(path.join(os.tmpdir(), 'rigour-scoped-')); });
12
+ afterEach(async () => { await fs.remove(cwd); });
13
+ it('context-drift reports only findings that cite a file in scope', async () => {
14
+ await fs.outputFile(path.join(cwd, 'src/a.ts'), 'export const a = process.env.API_URL_PROD;\n');
15
+ await fs.outputFile(path.join(cwd, 'src/b.ts'), 'export const b = process.env.API_URL_STAGING;\n');
16
+ const gate = new ContextGate({ context: { enabled: true } });
17
+ const record = { anchors: [{ type: 'env', id: 'API_URL', confidence: 1 }] };
18
+ const unscoped = await gate.run({ cwd, record });
19
+ const scoped = await gate.run({ cwd, record, patterns: ['src/a.ts'] });
20
+ expect(unscoped.flatMap(f => f.files)).toEqual(expect.arrayContaining(['src/a.ts', 'src/b.ts']));
21
+ expect(scoped.length).toBeGreaterThan(0);
22
+ expect(scoped.every(f => f.files?.includes('src/a.ts'))).toBe(true);
23
+ });
24
+ it('dependency-guardian checks the manifest only when package.json is in scope', async () => {
25
+ await fs.outputJson(path.join(cwd, 'package.json'), { dependencies: { 'left-pad': '1.0.0' } });
26
+ await fs.outputFile(path.join(cwd, 'src/a.ts'), "import pad from 'left-pad';\n");
27
+ const gate = new DependencyGate({ gates: { dependencies: { forbid: ['left-pad'] } } });
28
+ expect(await gate.run({ cwd, patterns: ['src/a.ts'] })).toEqual([]);
29
+ const withManifest = await gate.run({ cwd, patterns: ['package.json'] });
30
+ expect(withManifest.some(f => f.title === 'Forbidden Dependency')).toBe(true);
31
+ });
32
+ it('logic-drift never creates or rewrites its baseline from a scoped run', async () => {
33
+ const baseline = path.join(cwd, '.rigour/logic-baseline.json');
34
+ await fs.outputFile(path.join(cwd, 'src/rules.ts'), 'export function eligible(score: number) { return score >= 10; }\n');
35
+ await fs.outputFile(path.join(cwd, 'src/other.ts'), 'export function other(n: number) { return n > 1; }\n');
36
+ const gate = new LogicDriftGate();
37
+ expect(await gate.run({ cwd, patterns: ['src/rules.ts'] })).toEqual([]);
38
+ expect(await fs.pathExists(baseline)).toBe(false);
39
+ await gate.run({ cwd });
40
+ const before = await fs.readFile(baseline, 'utf-8');
41
+ await fs.outputFile(path.join(cwd, 'src/rules.ts'), 'export function eligible(score: number) { return score > 10; }\n');
42
+ const scoped = await gate.run({ cwd, patterns: ['src/rules.ts'] });
43
+ expect(scoped.some(f => f.details.includes("'>=' to '>'"))).toBe(true);
44
+ expect(await fs.readFile(baseline, 'utf-8')).toBe(before);
45
+ });
46
+ it('style-drift does not create its baseline from a scoped run', async () => {
47
+ await fs.outputFile(path.join(cwd, 'src/a.ts'), 'export function doThing() { return 1; }\n');
48
+ const gate = new StyleDriftGate();
49
+ expect(await gate.run({ cwd, patterns: ['src/a.ts'] })).toEqual([]);
50
+ expect(await fs.pathExists(path.join(cwd, '.rigour/style-baseline.json'))).toBe(false);
51
+ });
52
+ });
@@ -24,6 +24,7 @@ import { FileScanner } from '../utils/scanner.js';
24
24
  import { Logger } from '../utils/logger.js';
25
25
  import { languageAdapters } from './language-adapters/index.js';
26
26
  import { extractComparableJsNames } from './js-style-context.js';
27
+ import { isScoped } from '../utils/scope.js';
27
28
  import { TRY_CATCH_PATTERN, CATCH_PATTERN, RESULT_TYPE_PATTERN, NAMED_IMPORT_PATTERN, WILDCARD_IMPORT_PATTERN, SIDE_EFFECT_IMPORT_PATTERN, DEFAULT_IMPORT_PATTERN, countQuotes, } from './style-drift-rules.js';
28
29
  import fs from 'fs-extra';
29
30
  import path from 'path';
@@ -79,6 +80,8 @@ export class StyleDriftGate extends Gate {
79
80
  Logger.debug('Failed to load style baseline, will create new one');
80
81
  }
81
82
  }
83
+ if (!baseline && isScoped(context.patterns))
84
+ return []; // never baseline from a subset of files
82
85
  if (!baseline) {
83
86
  // First scan: create per-language baseline
84
87
  baseline = await this.computePerLanguageBaseline(context, filesByLang);
package/dist/index.d.ts CHANGED
@@ -9,6 +9,9 @@ export { RetryLoopBreakerGate } from './gates/retry-loop-breaker.js';
9
9
  export { SideEffectAnalysisGate } from './gates/side-effect-analysis/index.js';
10
10
  export { FrontendSecretExposureGate } from './gates/frontend-secret-exposure.js';
11
11
  export * from './utils/logger.js';
12
+ export { normalizeScopePatterns, isScoped } from './utils/scope.js';
13
+ export { deepAnalysisError } from './utils/deep-status.js';
14
+ export { parseDiff, changedLinesByFile } from './utils/diff.js';
12
15
  export { FileScanner } from './utils/scanner.js';
13
16
  export * from './services/score-history.js';
14
17
  export * from './hooks/index.js';
@@ -18,7 +21,9 @@ export { DeepAnalysisGate } from './gates/deep-analysis.js';
18
21
  export { createProvider } from './inference/index.js';
19
22
  export type { InferenceProvider, DeepFinding, DeepAnalysisResult, ModelTier } from './inference/types.js';
20
23
  export { MODELS } from './inference/types.js';
21
- export { isModelCached, getModelsDir, getModelInfo } from './inference/model-manager.js';
24
+ export { isModelCached, getModelsDir, getModelInfo, getCachedModel, ensureModel } from './inference/model-manager.js';
25
+ export { installLlamaEngine, probeBinary, managedEnginePath, LLAMA_RELEASE_TAG } from './inference/llama-engine.js';
26
+ export { SidecarProvider } from './inference/sidecar-provider.js';
22
27
  export { extractFacts, factsToPromptString } from './deep/fact-extractor.js';
23
28
  export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, insertScan, insertFindings, getRecentScans, getScoreTrendFromDB, getTopIssues, reinforcePattern, getStrongPatterns } from './storage/index.js';
24
29
  export type { RigourDB, CompactResult } from './storage/index.js';
package/dist/index.js CHANGED
@@ -9,6 +9,9 @@ export { RetryLoopBreakerGate } from './gates/retry-loop-breaker.js';
9
9
  export { SideEffectAnalysisGate } from './gates/side-effect-analysis/index.js';
10
10
  export { FrontendSecretExposureGate } from './gates/frontend-secret-exposure.js';
11
11
  export * from './utils/logger.js';
12
+ export { normalizeScopePatterns, isScoped } from './utils/scope.js';
13
+ export { deepAnalysisError } from './utils/deep-status.js';
14
+ export { parseDiff, changedLinesByFile } from './utils/diff.js';
12
15
  export { FileScanner } from './utils/scanner.js';
13
16
  export * from './services/score-history.js';
14
17
  export * from './hooks/index.js';
@@ -18,7 +21,9 @@ export { loadSettings, saveSettings, getSettingsPath, resolveDeepOptions, getPro
18
21
  export { DeepAnalysisGate } from './gates/deep-analysis.js';
19
22
  export { createProvider } from './inference/index.js';
20
23
  export { MODELS } from './inference/types.js';
21
- export { isModelCached, getModelsDir, getModelInfo } from './inference/model-manager.js';
24
+ export { isModelCached, getModelsDir, getModelInfo, getCachedModel, ensureModel } from './inference/model-manager.js';
25
+ export { installLlamaEngine, probeBinary, managedEnginePath, LLAMA_RELEASE_TAG } from './inference/llama-engine.js';
26
+ export { SidecarProvider } from './inference/sidecar-provider.js';
22
27
  export { extractFacts, factsToPromptString } from './deep/fact-extractor.js';
23
28
  // Storage (SQLite Brain)
24
29
  export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, resetDatabase, insertScan, insertFindings, getRecentScans, getScoreTrendFromDB, getTopIssues, reinforcePattern, getStrongPatterns } from './storage/index.js';
@@ -0,0 +1,22 @@
1
+ import { type Readable } from 'stream';
2
+ export interface HttpResponse {
3
+ statusCode: number;
4
+ headers: Record<string, string | string[] | undefined>;
5
+ body: Readable;
6
+ }
7
+ export type HttpGet = (url: string, headers: Record<string, string>, timeoutMs: number) => Promise<HttpResponse>;
8
+ export interface DownloadOptions {
9
+ onProgress?: (downloadedBytes: number, totalBytes: number | null) => void;
10
+ stallTimeoutMs?: number;
11
+ /** Resume from an existing partial file at `destPath` (default true). */
12
+ resume?: boolean;
13
+ /** Transport override for tests. */
14
+ get?: HttpGet;
15
+ }
16
+ export interface DownloadResult {
17
+ sha256: string;
18
+ bytes: number;
19
+ /** ETag seen on any hop; Hugging Face exposes the LFS SHA-256 as x-linked-etag. */
20
+ etag: string | null;
21
+ }
22
+ export declare function downloadToFile(url: string, destPath: string, options?: DownloadOptions): Promise<DownloadResult>;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Streaming HTTPS download for large binaries (models, engine archives).
3
+ *
4
+ * Uses node:https rather than global fetch: on large Hugging Face downloads
5
+ * undici's fetch measured ~1.7 MB/s where node:https reached ~17 MB/s on the
6
+ * same host and byte range, which made the first-run model download exceed
7
+ * any reasonable setup window.
8
+ *
9
+ * - follows at most MAX_REDIRECTS redirects, https only
10
+ * - resumes a kept partial file with a Range request (the prefix is re-hashed
11
+ * so the SHA-256 still covers the whole file)
12
+ * - aborts when no bytes arrive for `stallTimeoutMs` instead of imposing a
13
+ * wall-clock limit that a slow but healthy connection cannot meet
14
+ */
15
+ import https from 'https';
16
+ import fs from 'fs-extra';
17
+ import { createHash } from 'crypto';
18
+ import { Transform } from 'stream';
19
+ import { pipeline } from 'stream/promises';
20
+ const MAX_REDIRECTS = 5;
21
+ const DEFAULT_STALL_TIMEOUT_MS = 30_000;
22
+ export async function downloadToFile(url, destPath, options = {}) {
23
+ const stallMs = options.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
24
+ const get = options.get ?? httpsGet;
25
+ let existing = options.resume === false ? 0 : await partialSize(destPath);
26
+ let { response, etag } = await followRedirects(url, rangeFrom(existing), get, stallMs);
27
+ if (response.statusCode === 416 && existing > 0) {
28
+ // The partial is already at or past the end: start over once, from zero.
29
+ response.body.resume();
30
+ await fs.remove(destPath);
31
+ existing = 0;
32
+ ({ response, etag } = await followRedirects(url, rangeFrom(0), get, stallMs));
33
+ }
34
+ if (response.statusCode < 200 || response.statusCode >= 300) {
35
+ response.body.resume();
36
+ throw new Error(`HTTP ${response.statusCode} for ${url}`);
37
+ }
38
+ const resumed = response.statusCode === 206 && existing > 0;
39
+ const hash = createHash('sha256');
40
+ if (resumed)
41
+ await hashFilePrefix(destPath, hash);
42
+ const startBytes = resumed ? existing : 0;
43
+ const total = contentLength(response.headers, startBytes);
44
+ const bytes = await writeBody(response.body, destPath, hash, {
45
+ append: resumed, startBytes, total, stallMs, onProgress: options.onProgress,
46
+ });
47
+ return { sha256: hash.digest('hex'), bytes, etag };
48
+ }
49
+ function rangeFrom(offset) {
50
+ return offset > 0 ? { Range: `bytes=${offset}-` } : {};
51
+ }
52
+ async function partialSize(filePath) {
53
+ try {
54
+ return (await fs.stat(filePath)).size;
55
+ }
56
+ catch {
57
+ return 0;
58
+ }
59
+ }
60
+ async function followRedirects(url, headers, get, timeoutMs) {
61
+ let current = url;
62
+ let etag = null;
63
+ for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
64
+ if (new URL(current).protocol !== 'https:') {
65
+ throw new Error(`Refusing non-https download URL: ${current}`);
66
+ }
67
+ const response = await get(current, headers, timeoutMs);
68
+ etag = etag ?? headerValue(response.headers, 'x-linked-etag') ?? headerValue(response.headers, 'etag');
69
+ const location = headerValue(response.headers, 'location');
70
+ if (response.statusCode >= 300 && response.statusCode < 400 && location) {
71
+ response.body.resume();
72
+ current = new URL(location, current).toString();
73
+ continue;
74
+ }
75
+ return { response, etag };
76
+ }
77
+ throw new Error(`Too many redirects downloading ${url}`);
78
+ }
79
+ async function writeBody(body, destPath, hash, opts) {
80
+ let downloaded = opts.startBytes;
81
+ let stallTimer;
82
+ const armStall = () => {
83
+ clearTimeout(stallTimer);
84
+ stallTimer = setTimeout(() => {
85
+ body.destroy(new Error(`Download stalled: no data for ${opts.stallMs / 1000}s`));
86
+ }, opts.stallMs);
87
+ };
88
+ const tap = new Transform({
89
+ transform(chunk, _encoding, callback) {
90
+ armStall();
91
+ hash.update(chunk);
92
+ downloaded += chunk.length;
93
+ opts.onProgress?.(downloaded, opts.total);
94
+ callback(null, chunk);
95
+ },
96
+ });
97
+ armStall();
98
+ try {
99
+ await pipeline(body, tap, fs.createWriteStream(destPath, { flags: opts.append ? 'a' : 'w' }));
100
+ }
101
+ finally {
102
+ clearTimeout(stallTimer);
103
+ }
104
+ return downloaded;
105
+ }
106
+ async function hashFilePrefix(filePath, hash) {
107
+ const stream = fs.createReadStream(filePath);
108
+ try {
109
+ for await (const chunk of stream)
110
+ hash.update(chunk);
111
+ }
112
+ finally {
113
+ stream.destroy();
114
+ }
115
+ }
116
+ function contentLength(headers, offset) {
117
+ const value = Number(headerValue(headers, 'content-length'));
118
+ return Number.isFinite(value) && value > 0 ? value + offset : null;
119
+ }
120
+ function headerValue(headers, name) {
121
+ const value = headers[name];
122
+ if (Array.isArray(value))
123
+ return value[0] ?? null;
124
+ return value ?? null;
125
+ }
126
+ const httpsGet = (url, headers, timeoutMs) => new Promise((resolve, reject) => {
127
+ const request = https.get(url, { headers: { 'user-agent': 'rigour-cli', ...headers } }, (res) => {
128
+ resolve({ statusCode: res.statusCode ?? 0, headers: res.headers, body: res });
129
+ });
130
+ request.setTimeout(timeoutMs, () => request.destroy(new Error(`No response from ${new URL(url).host} after ${timeoutMs / 1000}s`)));
131
+ request.on('error', reject);
132
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,109 @@
1
+ import fs from 'fs-extra';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { createHash } from 'crypto';
5
+ import { Readable, PassThrough } from 'stream';
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+ import { downloadToFile } from './http-download.js';
8
+ const PAYLOAD = Buffer.from('0123456789abcdefghijklmnopqrstuvwxyz');
9
+ const sha = (b) => createHash('sha256').update(b).digest('hex');
10
+ function response(statusCode, body = Buffer.alloc(0), headers = {}) {
11
+ return { statusCode, headers, body: Buffer.isBuffer(body) ? Readable.from([body]) : body };
12
+ }
13
+ /** Serves PAYLOAD, honouring Range unless told not to. */
14
+ function server(opts = {}) {
15
+ const calls = [];
16
+ const get = async (url, headers) => {
17
+ calls.push({ url, headers });
18
+ const range = headers.Range?.match(/bytes=(\d+)-/);
19
+ if (range && opts.honourRange !== false) {
20
+ const start = Number(range[1]);
21
+ if (start >= PAYLOAD.length)
22
+ return response(416);
23
+ return response(206, PAYLOAD.subarray(start), { 'content-length': String(PAYLOAD.length - start) });
24
+ }
25
+ return response(200, PAYLOAD, { 'content-length': String(PAYLOAD.length), etag: opts.etag });
26
+ };
27
+ return { get, calls };
28
+ }
29
+ describe('downloadToFile', () => {
30
+ let dir;
31
+ let dest;
32
+ beforeEach(async () => {
33
+ dir = await fs.mkdtemp(path.join(os.tmpdir(), 'rigour-dl-'));
34
+ dest = path.join(dir, 'model.gguf.download');
35
+ });
36
+ afterEach(async () => { await fs.remove(dir); });
37
+ it('downloads the full body and hashes it', async () => {
38
+ const { get } = server({ etag: '"abc"' });
39
+ const result = await downloadToFile('https://host/model', dest, { get });
40
+ expect(await fs.readFile(dest)).toEqual(PAYLOAD);
41
+ expect(result).toEqual({ sha256: sha(PAYLOAD), bytes: PAYLOAD.length, etag: '"abc"' });
42
+ });
43
+ it('follows redirects and keeps the x-linked-etag from the first hop', async () => {
44
+ const { get: origin, calls } = server();
45
+ const get = async (url, headers, timeout) => {
46
+ if (url === 'https://hub/model') {
47
+ return response(302, Buffer.alloc(0), { location: 'https://cdn/model', 'x-linked-etag': '"lfs-sha"' });
48
+ }
49
+ return origin(url, headers, timeout);
50
+ };
51
+ const result = await downloadToFile('https://hub/model', dest, { get });
52
+ expect(calls.map(c => c.url)).toEqual(['https://cdn/model']);
53
+ expect(result.etag).toBe('"lfs-sha"');
54
+ });
55
+ it('resumes a partial file with a Range request and hashes the whole file', async () => {
56
+ await fs.writeFile(dest, PAYLOAD.subarray(0, 10));
57
+ const { get, calls } = server();
58
+ const result = await downloadToFile('https://host/model', dest, { get });
59
+ expect(calls[0].headers.Range).toBe('bytes=10-');
60
+ expect(await fs.readFile(dest)).toEqual(PAYLOAD);
61
+ expect(result.sha256).toBe(sha(PAYLOAD));
62
+ expect(result.bytes).toBe(PAYLOAD.length);
63
+ });
64
+ it('restarts from zero when the server ignores Range', async () => {
65
+ await fs.writeFile(dest, Buffer.from('stale-bytes'));
66
+ const { get } = server({ honourRange: false });
67
+ const result = await downloadToFile('https://host/model', dest, { get });
68
+ expect(await fs.readFile(dest)).toEqual(PAYLOAD);
69
+ expect(result.sha256).toBe(sha(PAYLOAD));
70
+ });
71
+ it('restarts when the partial is already past the end (416)', async () => {
72
+ await fs.writeFile(dest, Buffer.concat([PAYLOAD, Buffer.from('extra')]));
73
+ const { get, calls } = server();
74
+ await downloadToFile('https://host/model', dest, { get });
75
+ expect(calls).toHaveLength(2);
76
+ expect(await fs.readFile(dest)).toEqual(PAYLOAD);
77
+ });
78
+ it('rejects non-success responses without creating the file', async () => {
79
+ const get = async () => response(401);
80
+ await expect(downloadToFile('https://host/model', dest, { get })).rejects.toThrow('HTTP 401');
81
+ expect(await fs.pathExists(dest)).toBe(false);
82
+ });
83
+ it('refuses non-https URLs, including redirects to them', async () => {
84
+ const { get } = server();
85
+ await expect(downloadToFile('http://host/model', dest, { get })).rejects.toThrow('non-https');
86
+ const redirecting = async () => response(302, Buffer.alloc(0), { location: 'http://evil/model' });
87
+ await expect(downloadToFile('https://host/model', dest, { get: redirecting })).rejects.toThrow('non-https');
88
+ });
89
+ it('stops after too many redirects', async () => {
90
+ const loop = async (url) => response(302, Buffer.alloc(0), { location: `${url}x` });
91
+ await expect(downloadToFile('https://host/m', dest, { get: loop })).rejects.toThrow('Too many redirects');
92
+ });
93
+ it('aborts a stalled body and keeps the partial for the next resume', async () => {
94
+ const body = new PassThrough();
95
+ body.write(PAYLOAD.subarray(0, 5));
96
+ const get = async () => response(200, body, { 'content-length': String(PAYLOAD.length) });
97
+ await expect(downloadToFile('https://host/model', dest, { get, stallTimeoutMs: 50 })).rejects.toThrow('stalled');
98
+ // The partial file is kept for the next resume (how many bytes reached disk is up to the OS).
99
+ expect(await fs.pathExists(dest)).toBe(true);
100
+ expect((await fs.stat(dest)).size).toBeLessThanOrEqual(5);
101
+ });
102
+ it('reports progress against the full size when resuming', async () => {
103
+ await fs.writeFile(dest, PAYLOAD.subarray(0, 10));
104
+ const { get } = server();
105
+ const seen = [];
106
+ await downloadToFile('https://host/model', dest, { get, onProgress: (d, t) => seen.push([d, t]) });
107
+ expect(seen.at(-1)).toEqual([PAYLOAD.length, PAYLOAD.length]);
108
+ });
109
+ });