@rigour-labs/core 6.2.5 → 6.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/README.md +2 -0
  2. package/dist/discovery-signals.d.ts +27 -0
  3. package/dist/discovery-signals.js +96 -0
  4. package/dist/discovery.d.ts +1 -2
  5. package/dist/discovery.js +36 -49
  6. package/dist/discovery.test.js +84 -71
  7. package/dist/gates/deep-analysis.d.ts +2 -0
  8. package/dist/gates/deep-analysis.js +5 -1
  9. package/dist/gates/deep-intent.d.ts +3 -0
  10. package/dist/gates/deep-intent.js +57 -0
  11. package/dist/gates/deep-intent.test.d.ts +1 -0
  12. package/dist/gates/deep-intent.test.js +51 -0
  13. package/dist/gates/deep-runner.js +1 -0
  14. package/dist/gates/deprecated-apis-ast.d.ts +8 -0
  15. package/dist/gates/deprecated-apis-ast.js +25 -0
  16. package/dist/gates/deprecated-apis-rules-node.js +2 -8
  17. package/dist/gates/deprecated-apis.js +5 -1
  18. package/dist/gates/deprecated-apis.test.js +30 -0
  19. package/dist/gates/frontend-secret-exposure.js +5 -1
  20. package/dist/gates/frontend-secret-exposure.test.js +20 -0
  21. package/dist/gates/hallucinated-imports/framework-modules.d.ts +14 -0
  22. package/dist/gates/hallucinated-imports/framework-modules.js +75 -0
  23. package/dist/gates/hallucinated-imports/index.js +14 -6
  24. package/dist/gates/hallucinated-imports/js-resolver.d.ts +16 -12
  25. package/dist/gates/hallucinated-imports/js-resolver.js +65 -153
  26. package/dist/gates/hallucinated-imports/ts-path-target.js +16 -0
  27. package/dist/gates/hallucinated-imports/tsconfig-paths.d.ts +16 -0
  28. package/dist/gates/hallucinated-imports/tsconfig-paths.js +120 -0
  29. package/dist/gates/hallucinated-imports-sveltekit.test.d.ts +1 -0
  30. package/dist/gates/hallucinated-imports-sveltekit.test.js +108 -0
  31. package/dist/gates/runner.js +5 -0
  32. package/dist/gates/semantic-bugs.d.ts +23 -0
  33. package/dist/gates/semantic-bugs.js +48 -0
  34. package/dist/gates/semantic-bugs.test.d.ts +1 -0
  35. package/dist/gates/semantic-bugs.test.js +70 -0
  36. package/dist/gates/side-effect-analysis/index.js +11 -1
  37. package/dist/gates/side-effect-analysis.test.d.ts +1 -0
  38. package/dist/gates/side-effect-analysis.test.js +162 -0
  39. package/dist/gates/side-effect-helpers/pattern-detection.js +35 -7
  40. package/dist/gates/side-effect-helpers/resource-tracking.js +17 -10
  41. package/dist/gates/side-effect-helpers/scope-analysis.d.ts +6 -0
  42. package/dist/gates/side-effect-helpers/scope-analysis.js +20 -0
  43. package/dist/index.d.ts +5 -1
  44. package/dist/index.js +4 -1
  45. package/dist/inference/model-download.test.js +54 -2
  46. package/dist/inference/model-manager.d.ts +6 -0
  47. package/dist/inference/model-manager.js +58 -18
  48. package/dist/inference/sidecar-provider.js +12 -1
  49. package/dist/inference/types.d.ts +8 -2
  50. package/dist/inference/types.js +7 -3
  51. package/dist/semantic/ast.d.ts +29 -0
  52. package/dist/semantic/ast.js +122 -0
  53. package/dist/semantic/benchmark.d.ts +16 -0
  54. package/dist/semantic/benchmark.js +31 -0
  55. package/dist/semantic/benchmark.test.d.ts +1 -0
  56. package/dist/semantic/benchmark.test.js +18 -0
  57. package/dist/semantic/engine.d.ts +8 -0
  58. package/dist/semantic/engine.js +45 -0
  59. package/dist/semantic/intent/benchmark.d.ts +31 -0
  60. package/dist/semantic/intent/benchmark.js +74 -0
  61. package/dist/semantic/intent/intent-check.d.ts +43 -0
  62. package/dist/semantic/intent/intent-check.js +72 -0
  63. package/dist/semantic/intent/intent.test.d.ts +1 -0
  64. package/dist/semantic/intent/intent.test.js +46 -0
  65. package/dist/semantic/intent/parallel-reads.d.ts +23 -0
  66. package/dist/semantic/intent/parallel-reads.js +59 -0
  67. package/dist/semantic/learn/benchmark.d.ts +20 -0
  68. package/dist/semantic/learn/benchmark.js +71 -0
  69. package/dist/semantic/learn/compile.d.ts +4 -0
  70. package/dist/semantic/learn/compile.js +56 -0
  71. package/dist/semantic/learn/conditions.d.ts +22 -0
  72. package/dist/semantic/learn/conditions.js +93 -0
  73. package/dist/semantic/learn/extract.d.ts +30 -0
  74. package/dist/semantic/learn/extract.js +122 -0
  75. package/dist/semantic/learn/git-trees.d.ts +8 -0
  76. package/dist/semantic/learn/git-trees.js +32 -0
  77. package/dist/semantic/learn/identity.d.ts +18 -0
  78. package/dist/semantic/learn/identity.js +97 -0
  79. package/dist/semantic/learn/learn.d.ts +25 -0
  80. package/dist/semantic/learn/learn.js +109 -0
  81. package/dist/semantic/learn/learn.test.d.ts +1 -0
  82. package/dist/semantic/learn/learn.test.js +67 -0
  83. package/dist/semantic/learn/store.d.ts +4 -0
  84. package/dist/semantic/learn/store.js +39 -0
  85. package/dist/semantic/learn/types.d.ts +468 -0
  86. package/dist/semantic/learn/types.js +55 -0
  87. package/dist/semantic/origins.d.ts +42 -0
  88. package/dist/semantic/origins.js +149 -0
  89. package/dist/semantic/origins.test.d.ts +1 -0
  90. package/dist/semantic/origins.test.js +77 -0
  91. package/dist/semantic/program.d.ts +13 -0
  92. package/dist/semantic/program.js +62 -0
  93. package/dist/semantic/rules/credential-redirect.d.ts +2 -0
  94. package/dist/semantic/rules/credential-redirect.js +209 -0
  95. package/dist/semantic/rules/degraded-response-cached.d.ts +2 -0
  96. package/dist/semantic/rules/degraded-response-cached.js +271 -0
  97. package/dist/semantic/rules/in-memory-aggregation.d.ts +2 -0
  98. package/dist/semantic/rules/in-memory-aggregation.js +179 -0
  99. package/dist/semantic/summaries.d.ts +18 -0
  100. package/dist/semantic/summaries.js +33 -0
  101. package/dist/semantic/types.d.ts +32 -0
  102. package/dist/semantic/types.js +1 -0
  103. package/dist/storage/index.d.ts +1 -0
  104. package/dist/storage/index.js +1 -0
  105. package/dist/storage/team-diagnostics.d.ts +22 -0
  106. package/dist/storage/team-diagnostics.js +58 -0
  107. package/dist/storage/team-diagnostics.test.d.ts +1 -0
  108. package/dist/storage/team-diagnostics.test.js +51 -0
  109. package/dist/storage/team-schema.d.ts +6 -0
  110. package/dist/storage/team-schema.js +82 -0
  111. package/dist/storage/team-store.js +25 -72
  112. package/dist/templates/universal-config.js +4 -0
  113. package/dist/types/index.d.ts +56 -0
  114. package/dist/types/index.js +7 -0
  115. package/package.json +6 -6
@@ -71,7 +71,7 @@ export function pathsOverlap(watchPath, writePath) {
71
71
  // ═══════════════════════════════════════════════════════════════════
72
72
  // LOOP & RECURSION ANALYSIS — Context-aware body extraction
73
73
  // ═══════════════════════════════════════════════════════════════════
74
- import { findBlockEndBrace, findBlockEndIndent } from './scope-analysis.js';
74
+ import { findBlockEndBrace, findBlockEndIndent, findExpressionEnd } from './scope-analysis.js';
75
75
  /**
76
76
  * Extract loop body with correct scope tracking.
77
77
  * Uses brace/indent matching (not just "next N lines").
@@ -122,13 +122,24 @@ export function extractFunctionDefs(lines, lang) {
122
122
  ? findBlockEndIndent(lines, i)
123
123
  : lang === 'rb'
124
124
  ? findBlockEndRuby(lines, i)
125
- : findBlockEndBrace(lines, i);
125
+ : isExpressionArrow(lines, i, stripped, m.index) ? findExpressionEnd(lines, i) : findBlockEndBrace(lines, i);
126
126
  defs.push({ name, start: i, end, params: lines[i] });
127
127
  break;
128
128
  }
129
129
  }
130
130
  return defs;
131
131
  }
132
+ /** An arrow whose body is an expression, not a `{ ... }` block. */
133
+ function isExpressionArrow(lines, line, stripped, matchAt) {
134
+ const arrow = stripped.indexOf('=>', matchAt);
135
+ if (arrow < 0)
136
+ return false;
137
+ const rest = stripped.slice(arrow + 2).trim();
138
+ if (rest !== '')
139
+ return !rest.startsWith('{');
140
+ const next = lines.slice(line + 1).find(l => l.trim() !== '');
141
+ return next !== undefined && !next.trim().startsWith('{');
142
+ }
132
143
  function getFuncDefPatterns(lang) {
133
144
  switch (lang) {
134
145
  case 'py': return [/^\s*(?:async\s+)?def\s+(\w+)\s*\(/];
@@ -297,11 +308,24 @@ export function hasCatchWithContinue(body, lang) {
297
308
  // ═══════════════════════════════════════════════════════════════════
298
309
  // PROCESS & TIMER OPERATIONS
299
310
  // ═══════════════════════════════════════════════════════════════════
311
+ /**
312
+ * Drop a trailing line comment (and whole comment lines) so prose such as
313
+ * "ported from the fork (verified …)" is not read as code. Strings are
314
+ * already blanked, so `//` inside a URL string is gone before this runs.
315
+ */
316
+ function stripLineComment(line, lang) {
317
+ const trimmed = line.trim();
318
+ if (trimmed.startsWith('*') || trimmed.startsWith('/*'))
319
+ return '';
320
+ const marker = lang === 'py' || lang === 'rb' ? '#' : '//';
321
+ const at = line.indexOf(marker);
322
+ return at === -1 ? line : line.slice(0, at);
323
+ }
300
324
  /**
301
325
  * Check if a line contains a process spawn call.
302
326
  */
303
327
  export function isProcessSpawn(line, lang) {
304
- const stripped = stripStrings(line);
328
+ const stripped = stripLineComment(stripStrings(line), lang);
305
329
  const patterns = getSpawnPatterns(lang);
306
330
  for (const pat of patterns) {
307
331
  const m = pat.exec(stripped);
@@ -314,9 +338,13 @@ function getSpawnPatterns(lang) {
314
338
  switch (lang) {
315
339
  case 'js':
316
340
  case 'ts':
341
+ // A bare call (from a child_process import) or a call on a
342
+ // child_process namespace; `re.exec(src)` or `db.exec(sql)` is not a
343
+ // spawn. *Sync variants finish before returning, so they cannot orphan.
317
344
  return [
318
- /\b(?:spawn|exec|execFile|fork|execa)\s*\(/,
319
- /\bchild_process\.\w+\s*\(/,
345
+ /(?<![.\w$])(?:spawn|exec|execFile|fork)\s*\(/,
346
+ /(?<![.\w$])(?:execa|execaCommand)\s*\(/,
347
+ /\b(?:child_process|childProcess|cp)\.(?:spawn|exec|execFile|fork)\s*\(/,
320
348
  ];
321
349
  case 'py':
322
350
  return [
@@ -354,9 +382,9 @@ function getTimerCreatePatterns(lang) {
354
382
  switch (lang) {
355
383
  case 'js':
356
384
  case 'ts':
385
+ // setTimeout fires once and cannot run unbounded; only setInterval repeats.
357
386
  return [
358
387
  [/\bsetInterval\s*\(/, 'setInterval'],
359
- [/\bsetTimeout\s*\(/, 'setTimeout'],
360
388
  ];
361
389
  case 'py':
362
390
  return [
@@ -410,7 +438,7 @@ export function getProcessCleanupPatterns(lang) {
410
438
  case 'js':
411
439
  case 'ts':
412
440
  return [
413
- /\.on\s*\(\s*['"](?:exit|close)['"]/,
441
+ /\.(?:on|once|addListener)\s*\(\s*['"](?:exit|close)['"]/,
414
442
  /\.kill\s*\(/,
415
443
  /\.disconnect\s*\(/,
416
444
  ];
@@ -39,8 +39,8 @@ export function extractVariableBinding(line, lang) {
39
39
  const jsMatch = stripped.match(/^(?:const|let|var|final|auto|val)\s+(\w+)\s*=\s*/);
40
40
  if (jsMatch)
41
41
  return jsMatch[1];
42
- // Member assignment: `this.timer = ...`, `self.timer = ...`
43
- const memberMatch = stripped.match(/^(?:this|self)\.([\w]+)\s*=\s*/);
42
+ // Member assignment: `this.timer = ...`, `self.timer = ...`, `this.#timer = ...`
43
+ const memberMatch = stripped.match(/^(?:this|self)\.(#?[\w]+)\s*=\s*/);
44
44
  if (memberMatch)
45
45
  return memberMatch[1];
46
46
  // Simple assignment: `timer = ...`
@@ -68,26 +68,33 @@ export function hasCleanupForVariable(lines, varName, scopeStart, scopeEnd, clea
68
68
  const scope = lines.slice(scopeStart, scopeEnd);
69
69
  for (let i = 0; i < scope.length; i++) {
70
70
  const stripped = stripStrings(scope[i]);
71
- // Check cleanup patterns that reference the specific variable
71
+ // Check cleanup patterns that reference the specific variable. Some
72
+ // patterns need the quoted event name (`.once('exit', …)`), which
73
+ // stripStrings blanks, so they are also tried on the raw line.
72
74
  for (const pat of cleanupPatterns) {
73
- if (!pat.test(stripped))
75
+ if (!pat.test(stripped) && !pat.test(scope[i]))
74
76
  continue;
75
- // The cleanup call should reference our variable
76
- const escapedVar = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
77
- const varRef = new RegExp(`\\b${escapedVar}\\b`);
78
- if (varRef.test(stripped))
77
+ // The cleanup call should reference our variable. Lookarounds, not
78
+ // \b, so a private field such as `#timer` matches too.
79
+ if (variableReference(varName).test(stripped))
79
80
  return true;
80
81
  // Also check method calls on the variable: timer.close(), timer.stop()
81
82
  // The pattern might match a generic .close() — check if it's on our var
82
83
  }
83
84
  // Direct method cleanup on the variable: varName.close(), varName.Stop()
84
- const escapedVar = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
85
- const methodCleanup = new RegExp(`\\b${escapedVar}\\.(?:close|stop|destroy|kill|terminate|dispose|cancel|shutdown|unsubscribe|disconnect|end|release|Clear|Stop|Dispose|Close|Cancel)\\s*\\(`);
85
+ const escapedVar = escapeRegExp(varName);
86
+ const methodCleanup = new RegExp(`(?<![\\w$#])${escapedVar}\\.(?:close|stop|destroy|kill|terminate|dispose|cancel|shutdown|unsubscribe|disconnect|end|release|Clear|Stop|Dispose|Close|Cancel)\\s*\\(`);
86
87
  if (methodCleanup.test(stripped))
87
88
  return true;
88
89
  }
89
90
  return false;
90
91
  }
92
+ function escapeRegExp(value) {
93
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
94
+ }
95
+ function variableReference(varName) {
96
+ return new RegExp(`(?<![\\w$#])${escapeRegExp(varName)}(?![\\w$])`);
97
+ }
91
98
  /**
92
99
  * Check if a line is inside a cleanup/teardown context.
93
100
  *
@@ -14,6 +14,12 @@ export declare function findEnclosingFunction(lines: string[], lineIdx: number,
14
14
  * Find the end of a brace-delimited block starting at `start`.
15
15
  */
16
16
  export declare function findBlockEndBrace(lines: string[], start: number): number;
17
+ /**
18
+ * Find the end of an expression-bodied arrow (`const f = (x) => x + 1;`):
19
+ * the first line where brackets balance and the expression does not
20
+ * continue. Scanning for a `{` instead would run into the next block.
21
+ */
22
+ export declare function findExpressionEnd(lines: string[], start: number): number;
17
23
  /**
18
24
  * Find the end of an indentation-delimited block (Python).
19
25
  */
@@ -95,6 +95,26 @@ export function findBlockEndBrace(lines, start) {
95
95
  }
96
96
  return maxScan;
97
97
  }
98
+ /**
99
+ * Find the end of an expression-bodied arrow (`const f = (x) => x + 1;`):
100
+ * the first line where brackets balance and the expression does not
101
+ * continue. Scanning for a `{` instead would run into the next block.
102
+ */
103
+ export function findExpressionEnd(lines, start) {
104
+ let depth = 0;
105
+ const maxScan = Math.min(lines.length, start + 300);
106
+ for (let j = start; j < maxScan; j++) {
107
+ const stripped = stripStrings(lines[j]);
108
+ depth += bracketDelta(stripped);
109
+ const continues = /(?:=>|[=+\-*/%&|?:,.(])\s*$/.test(stripped.trimEnd());
110
+ if (depth <= 0 && !continues)
111
+ return j + 1;
112
+ }
113
+ return maxScan;
114
+ }
115
+ function bracketDelta(text) {
116
+ return (text.match(/[([{]/g)?.length ?? 0) - (text.match(/[)\]}]/g)?.length ?? 0);
117
+ }
98
118
  /**
99
119
  * Find the end of an indentation-delimited block (Python).
100
120
  */
package/dist/index.d.ts CHANGED
@@ -31,7 +31,7 @@ export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './stor
31
31
  export type { ProjectStats } from './storage/index.js';
32
32
  export { recordInteractionEvidence, countInteractionEvidence, recordInteractionLesson, listLessons, listKnowledgeLessons, getApplicableLessons, transitionLesson, getRepositoryId, queueLocalLessonsForTeam } from './storage/index.js';
33
33
  export type { ApplicableLessons, InteractionEvidence, LessonRecord, LessonState, LessonVisibility, LocalLessonImportOptions, LocalLessonImportResult } from './storage/index.js';
34
- export { loadTeamConfiguration, saveTeamConfiguration, initializeTeamSchema, getTeamModeStatus, doctorTeamConnection, syncTeamOutbox, searchTeamKnowledge, backfillTeamEmbeddings, TEAM_CONFIG_PATH, validateTeamDatabaseUrl, } from './storage/index.js';
34
+ export { loadTeamConfiguration, saveTeamConfiguration, initializeTeamSchema, getTeamModeStatus, doctorTeamConnection, syncTeamOutbox, searchTeamKnowledge, backfillTeamEmbeddings, TEAM_CONFIG_PATH, validateTeamDatabaseUrl, explainTeamConnectionError, } from './storage/index.js';
35
35
  export type { TeamConfiguration, TeamSemanticConfiguration, TeamModeStatus, TeamDoctorResult, SemanticKnowledgeCandidate, SemanticKnowledgeResult, } from './storage/index.js';
36
36
  export { generateTemporalDriftReport, formatDriftSummary } from './services/temporal-drift.js';
37
37
  export type { TemporalDriftReport, ProvenanceStream, MonthlyBucket, WeeklyBucket, DriftDirection } from './services/temporal-drift.js';
@@ -51,4 +51,8 @@ export * from './services/system-health.js';
51
51
  export * from './services/agent-history.js';
52
52
  export * from './services/engineering-knowledge-graph.js';
53
53
  export * from './firewall/index.js';
54
+ export { learnFromFix, type LearnReport, type Rejection } from './semantic/learn/learn.js';
55
+ export { extractFixTrees, type FixTrees } from './semantic/learn/git-trees.js';
56
+ export { saveLearnedRule, loadLearnedRules, LEARNED_RULES_DIR } from './semantic/learn/store.js';
57
+ export type { LearnedRule, LearnedPattern } from './semantic/learn/types.js';
54
58
  export { recordContextEvent, recordModelUsage, recordCheckpointMetric, getContextEvents, getModelUsages, getCheckpointMetrics, } from './storage/index.js';
package/dist/index.js CHANGED
@@ -30,7 +30,7 @@ export { openDatabase, isSQLiteAvailable, compactDatabase, getDatabaseSize, rese
30
30
  // Local Project Memory (hybrid intelligence — SQLite-backed per-project learning)
31
31
  export { checkLocalPatterns, persistAndReinforce, getProjectStats } from './storage/index.js';
32
32
  export { recordInteractionEvidence, countInteractionEvidence, recordInteractionLesson, listLessons, listKnowledgeLessons, getApplicableLessons, transitionLesson, getRepositoryId, queueLocalLessonsForTeam } from './storage/index.js';
33
- export { loadTeamConfiguration, saveTeamConfiguration, initializeTeamSchema, getTeamModeStatus, doctorTeamConnection, syncTeamOutbox, searchTeamKnowledge, backfillTeamEmbeddings, TEAM_CONFIG_PATH, validateTeamDatabaseUrl, } from './storage/index.js';
33
+ export { loadTeamConfiguration, saveTeamConfiguration, initializeTeamSchema, getTeamModeStatus, doctorTeamConnection, syncTeamOutbox, searchTeamKnowledge, backfillTeamEmbeddings, TEAM_CONFIG_PATH, validateTeamDatabaseUrl, explainTeamConnectionError, } from './storage/index.js';
34
34
  // Temporal Drift Engine (v5 — cross-session trend analysis + per-provenance EWMA)
35
35
  export { generateTemporalDriftReport, formatDriftSummary } from './services/temporal-drift.js';
36
36
  // Adaptive Thresholds (v5 — Z-score + per-provenance trends)
@@ -50,6 +50,9 @@ export * from './services/system-health.js';
50
50
  export * from './services/agent-history.js';
51
51
  export * from './services/engineering-knowledge-graph.js';
52
52
  export * from './firewall/index.js';
53
+ export { learnFromFix } from './semantic/learn/learn.js';
54
+ export { extractFixTrees } from './semantic/learn/git-trees.js';
55
+ export { saveLearnedRule, loadLearnedRules, LEARNED_RULES_DIR } from './semantic/learn/store.js';
53
56
  export { recordContextEvent, recordModelUsage, recordCheckpointMetric, getContextEvents, getModelUsages, getCheckpointMetrics, } from './storage/index.js';
54
57
  // Pattern Index is intentionally NOT exported here to prevent
55
58
  // native dependency issues (sharp/transformers) from leaking into
@@ -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: '2.0.0', checkedAt: new Date().toISOString() });
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
- if (new Date(metadata.verifiedAt).getTime() < stat.mtimeMs)
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
- if (age < VERSION_CHECK_INTERVAL_MS && cached.version) {
179
- const v = String(cached.version);
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
- // Handle both legacy integer versions (5 → "5.0.0") and SemVer strings ("2.0.0")
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
- return await downloadFromUrl(model, onProgress, get);
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 && fallback.url !== model.url) {
241
- onProgress?.(`Fine-tuned model unavailable (${error?.message ?? 'unknown error'}); using stock ${fallback.name}`, 0);
242
- return downloadFromUrl(fallback, onProgress, get);
243
- }
244
- throw error;
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
  /**
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * Code never leaves the machine: prompts go to a local child process.
11
11
  */
12
+ import crypto from 'crypto';
12
13
  import path from 'path';
13
14
  import os from 'os';
14
15
  import fs from 'fs-extra';
@@ -88,7 +89,7 @@ export class SidecarProvider {
88
89
  maxTokens: options?.maxTokens || 1024,
89
90
  threads: this.threads,
90
91
  temperature: options?.temperature ?? 0.1,
91
- schemaPath: options?.jsonMode ? this.schemaPath ?? undefined : undefined,
92
+ schemaPath: options?.jsonSchema ? await schemaFileFor(options.jsonSchema) : options?.jsonMode ? this.schemaPath ?? undefined : undefined,
92
93
  });
93
94
  let result;
94
95
  try {
@@ -159,6 +160,16 @@ async function findOnPath(name) {
159
160
  return null;
160
161
  }
161
162
  }
163
+ /** A schema file per distinct schema, written once and reused across calls. */
164
+ async function schemaFileFor(schema) {
165
+ const text = JSON.stringify(schema);
166
+ const schemaPath = path.join(managedEngineDir(), '..', 'schemas', `${crypto.createHash('sha256').update(text).digest('hex').slice(0, 16)}.json`);
167
+ if (!(await fs.pathExists(schemaPath))) {
168
+ await fs.ensureDir(path.dirname(schemaPath));
169
+ await fs.writeFile(schemaPath, text);
170
+ }
171
+ return schemaPath;
172
+ }
162
173
  async function writeFindingsSchema() {
163
174
  const schemaPath = path.join(managedEngineDir(), '..', 'findings.schema.json');
164
175
  await fs.ensureDir(path.dirname(schemaPath));
@@ -29,6 +29,8 @@ export interface InferenceOptions {
29
29
  temperature?: number;
30
30
  timeout?: number;
31
31
  jsonMode?: boolean;
32
+ /** Constrain local output to this JSON schema instead of the findings schema. */
33
+ jsonSchema?: Record<string, unknown>;
32
34
  }
33
35
  /**
34
36
  * A single finding from deep LLM analysis.
@@ -84,12 +86,16 @@ export interface ModelInfo {
84
86
  * At startup, model-manager checks HF for the latest version and downloads
85
87
  * it automatically (like antivirus signature updates).
86
88
  *
87
- * Version format: SemVer (MAJOR.MINOR.PATCH)
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
88
94
  * - MAJOR: Training data format change, base model change, pipeline architecture
89
95
  * - MINOR: New training repos, updated dataset, hyperparameter improvements
90
96
  * - PATCH: Bug fixes, retraining with same data/format
91
97
  */
92
- export declare const BUNDLED_MODEL_VERSION = "2.0.0";
98
+ export declare const BUNDLED_MODEL_VERSION = "5";
93
99
  /** HuggingFace dataset repo where latest_version.json lives */
94
100
  export declare const VERSION_CHECK_URL = "https://huggingface.co/datasets/rigour-labs/rigour-rlaif-data/resolve/main/latest_version.json";
95
101
  /** Build model info for a given tier and version */
@@ -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
- * Version format: SemVer (MAJOR.MINOR.PATCH)
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 = '2.0.0';
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: 500_000_000, sizeH: '500MB' },
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,29 @@
1
+ import ts from 'typescript';
2
+ export type FunctionLike = ts.FunctionDeclaration | ts.FunctionExpression | ts.ArrowFunction | ts.MethodDeclaration;
3
+ export declare function isFunctionLike(node: ts.Node): node is FunctionLike;
4
+ /** Drop parentheses, type assertions, non-null and `await` around an expression. */
5
+ export declare function unwrap(expr: ts.Expression): ts.Expression;
6
+ /**
7
+ * Symbol for a node, following import aliases to the declaration. For the
8
+ * name in a shorthand property (`{ headers }`) this is the variable it reads,
9
+ * not the property it creates.
10
+ */
11
+ export declare function symbolOf(checker: ts.TypeChecker, node: ts.Node): ts.Symbol | undefined;
12
+ /** The function a call invokes, when it resolves to one declared in source. */
13
+ export declare function calledFunction(checker: ts.TypeChecker, call: ts.CallExpression): FunctionLike | undefined;
14
+ /** `fetch` in `fetch(...)`, `deps.fetch(...)`, `this.fetch(...)`. */
15
+ export declare function calleeName(call: ts.CallExpression): string | undefined;
16
+ export declare function enclosingFunction(node: ts.Node): FunctionLike | undefined;
17
+ /** Visit every descendant of `root` that belongs to `root` itself, not to a nested function. */
18
+ export declare function forEachOwnNode(root: ts.Node, visit: (node: ts.Node) => void): void;
19
+ export declare function forEachNode(root: ts.Node, visit: (node: ts.Node) => void): void;
20
+ /** Text of a property name (`a`, `'a'`, `"x-token"`), or undefined for computed names. */
21
+ export declare function propertyNameText(name: ts.PropertyName | undefined): string | undefined;
22
+ export declare function lineOf(node: ts.Node): number;
23
+ /** "http.ts:51" style location for evidence, relative to cwd. */
24
+ export declare function locationOf(cwd: string, node: ts.Node): string;
25
+ export declare function relativeFile(cwd: string, sf: ts.SourceFile): string;
26
+ /** Short, single-line source text for messages. */
27
+ export declare function snippet(node: ts.Node, max?: number): string;
28
+ /** True when any identifier inside `expr` resolves to `target`. */
29
+ export declare function mentionsSymbol(checker: ts.TypeChecker, expr: ts.Node, target: ts.Symbol): boolean;