@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
package/README.md CHANGED
@@ -26,6 +26,8 @@ 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, opt-in):** 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
+
29
31
  **Agent Governance:** Multi-agent scope isolation, EWMA-based checkpoint supervision, context drift, retry loop breaker, memory & skills governance with DLP scanning.
30
32
 
31
33
  ### Real-Time Hook Engine
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Framework and library markers. These are decided by the project's declared
3
+ * dependencies only: as words they are too common in code and prose
4
+ * ("next()" in Express middleware, "reactivity" in a Svelte comment).
5
+ */
6
+ export declare const PACKAGE_MARKERS: Set<string>;
7
+ /** Declared dependency names: package.json sections, requirements.txt and pyproject.toml. */
8
+ export declare function dependencyNames(cwd: string): Promise<Set<string>>;
9
+ /** `marker` is declared as a dependency, directly or as its scope (`@nestjs/core`). */
10
+ export declare function declaresPackage(dependencies: Set<string>, marker: string): boolean;
11
+ /** Up to MAX_SAMPLE project source files (not configs or tests), spread evenly so one package cannot fill the sample. */
12
+ export declare function sampleSourceFiles(cwd: string): Promise<string[]>;
13
+ /** Markers naming a file extension (`ipynb`): proven by one such file existing, not by the word. */
14
+ export declare const EXTENSION_MARKERS: Set<string>;
15
+ export declare function hasFileWithExtension(cwd: string, extension: string): Promise<boolean>;
16
+ /** A marker naming a file or folder (`next.config.js`, `k8s/`, `Dockerfile`), which only its existence proves. */
17
+ export declare function isPathMarker(marker: string): boolean;
18
+ /** `marker` as a whole token, not inside a longer word or package name. */
19
+ export declare function containsToken(text: string, marker: string): boolean;
20
+ /** Drop line and block comments so prose is not read as code. */
21
+ export declare function stripComments(text: string): string;
22
+ export interface ParadigmSignal {
23
+ name: 'oop' | 'functional';
24
+ marker: string;
25
+ }
26
+ /** OOP or functional by declaration counts in comment-free sources; null when neither clearly dominates. */
27
+ export declare function detectParadigm(contents: string[]): ParadigmSignal | null;
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Signals for `rigour init` discovery.
3
+ *
4
+ * Substring search misread projects: "reactivity" in an ESLint comment made
5
+ * a Svelte repo "react", and "the public internet" in a Playwright comment
6
+ * made it "OOP". Framework markers now match package.json dependency names,
7
+ * other markers match whole tokens, and the paradigm comes from counting
8
+ * declarations in real source files, with no guess when neither side leads.
9
+ */
10
+ import fs from 'fs-extra';
11
+ import path from 'path';
12
+ import { globby } from 'globby';
13
+ const SOURCE_EXT = '{ts,tsx,js,jsx,mjs,py,go,java,kt,rb}';
14
+ const SOURCE_GLOBS = [
15
+ `{src,lib,app,pkg,api,internal,cmd}/**/*.${SOURCE_EXT}`,
16
+ `{packages,apps,services}/*/{src,lib,app}/**/*.${SOURCE_EXT}`,
17
+ '*.{py,go}',
18
+ ];
19
+ const SOURCE_IGNORE = ['**/node_modules/**', '**/dist/**', '**/build/**', '**/*.d.ts', '**/*.config.*', '**/*.{test,spec}.*', '**/__tests__/**'];
20
+ const MAX_SAMPLE = 40;
21
+ /**
22
+ * Framework and library markers. These are decided by the project's declared
23
+ * dependencies only: as words they are too common in code and prose
24
+ * ("next()" in Express middleware, "reactivity" in a Svelte comment).
25
+ */
26
+ export const PACKAGE_MARKERS = new Set(['react', 'next', 'vue', 'svelte', 'express', 'fastify', 'nestjs', 'spark', 'pandas']);
27
+ /** Declared dependency names: package.json sections, requirements.txt and pyproject.toml. */
28
+ export async function dependencyNames(cwd) {
29
+ const names = new Set();
30
+ const pkg = await fs.readJson(path.join(cwd, 'package.json')).catch(() => null);
31
+ for (const section of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) {
32
+ for (const name of Object.keys(pkg?.[section] ?? {}))
33
+ names.add(name);
34
+ }
35
+ const requirements = await fs.readFile(path.join(cwd, 'requirements.txt'), 'utf-8').catch(() => '');
36
+ for (const match of requirements.matchAll(/^\s*([A-Za-z0-9][\w.-]*)/gm))
37
+ names.add(match[1].toLowerCase());
38
+ const pyproject = await fs.readFile(path.join(cwd, 'pyproject.toml'), 'utf-8').catch(() => '');
39
+ for (const match of pyproject.matchAll(/["']([A-Za-z0-9][\w.-]*)\s*(?:\[[^\]]*\])?\s*(?:[<>=~!;]|["'])/g))
40
+ names.add(match[1].toLowerCase());
41
+ return names;
42
+ }
43
+ /** `marker` is declared as a dependency, directly or as its scope (`@nestjs/core`). */
44
+ export function declaresPackage(dependencies, marker) {
45
+ if (dependencies.has(marker))
46
+ return true;
47
+ for (const name of dependencies)
48
+ if (name.startsWith(`@${marker}/`))
49
+ return true;
50
+ return false;
51
+ }
52
+ /** Up to MAX_SAMPLE project source files (not configs or tests), spread evenly so one package cannot fill the sample. */
53
+ export async function sampleSourceFiles(cwd) {
54
+ const files = (await globby(SOURCE_GLOBS, { cwd, ignore: SOURCE_IGNORE, gitignore: false, followSymbolicLinks: false })).sort();
55
+ const stride = Math.max(1, files.length / MAX_SAMPLE);
56
+ const sample = [];
57
+ for (let i = 0; i < files.length && sample.length < MAX_SAMPLE; i += stride)
58
+ sample.push(files[Math.floor(i)]);
59
+ return sample.map(f => path.join(cwd, f));
60
+ }
61
+ /** Markers naming a file extension (`ipynb`): proven by one such file existing, not by the word. */
62
+ export const EXTENSION_MARKERS = new Set(['ipynb']);
63
+ export async function hasFileWithExtension(cwd, extension) {
64
+ const found = await globby(`**/*.${extension}`, { cwd, ignore: ['**/node_modules/**', '**/.git/**'], gitignore: false, followSymbolicLinks: false, deep: 4 });
65
+ return found.length > 0;
66
+ }
67
+ /** A marker naming a file or folder (`next.config.js`, `k8s/`, `Dockerfile`), which only its existence proves. */
68
+ export function isPathMarker(marker) {
69
+ return /[./]/.test(marker) || /^[A-Z]/.test(marker);
70
+ }
71
+ /** `marker` as a whole token, not inside a longer word or package name. */
72
+ export function containsToken(text, marker) {
73
+ const escaped = marker.trim().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
74
+ return new RegExp(`(?<![\\w@/.-])${escaped}(?![\\w-])`, 'i').test(text);
75
+ }
76
+ /** Drop line and block comments so prose is not read as code. */
77
+ export function stripComments(text) {
78
+ return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:"'`])\/\/.*$/gm, '$1').replace(/^\s*#.*$/gm, '');
79
+ }
80
+ const CLASS_DECLARATION = /^\s*(?:export\s+)?(?:default\s+)?(?:abstract\s+)?(?:public\s+)?class\s+[A-Z]\w*/gm;
81
+ const FUNCTION_DECLARATION = /^\s*(?:export\s+(?:default\s+)?(?:async\s+)?function\b|export\s+const\s+\w+\s*(?::[^=]+)?=\s*(?:async\s*)?(?:\([^)]*\)|\w+)\s*(?::[^=]+)?=>|def\s+\w+\s*\(|func\s+\w+\s*\()/gm;
82
+ /** OOP or functional by declaration counts in comment-free sources; null when neither clearly dominates. */
83
+ export function detectParadigm(contents) {
84
+ let classes = 0;
85
+ let functions = 0;
86
+ for (const text of contents) {
87
+ classes += text.match(CLASS_DECLARATION)?.length ?? 0;
88
+ functions += text.match(FUNCTION_DECLARATION)?.length ?? 0;
89
+ }
90
+ const marker = `declarations: ${classes} classes, ${functions} functions`;
91
+ if (classes > 0 && classes >= 2 * functions)
92
+ return { name: 'oop', marker };
93
+ if (functions > 0 && functions >= 2 * classes)
94
+ return { name: 'functional', marker };
95
+ return null;
96
+ }
@@ -16,6 +16,5 @@ export declare class DiscoveryService {
16
16
  discover(cwd: string): Promise<DiscoveryResult>;
17
17
  private mergeConfig;
18
18
  private findFirstMarker;
19
- private existsInContent;
20
- private findSourceFiles;
19
+ private recurs;
21
20
  }
package/dist/discovery.js CHANGED
@@ -1,28 +1,37 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import { TEMPLATES, PARADIGM_TEMPLATES, UNIVERSAL_CONFIG } from './templates/index.js';
4
+ import { EXTENSION_MARKERS, PACKAGE_MARKERS, containsToken, hasFileWithExtension, declaresPackage, dependencyNames, detectParadigm, isPathMarker, sampleSourceFiles, stripComments, } from './discovery-signals.js';
5
+ /**
6
+ * Domain keywords are ambiguous alone ("health" score, "transaction" block), and a
7
+ * domain preset adds compliance docs and stricter gates. It needs several keywords,
8
+ * each recurring across files rather than listed once.
9
+ */
10
+ const MIN_KEYWORDS = 2;
11
+ const MIN_KEYWORD_FILES = 2;
4
12
  export class DiscoveryService {
5
13
  async discover(cwd) {
6
14
  let config = { ...UNIVERSAL_CONFIG };
7
15
  const matches = {};
8
- // 1. Detect Role (ui, api, infra, data)
16
+ const dependencies = await dependencyNames(cwd);
17
+ const files = await sampleSourceFiles(cwd);
18
+ const contents = await Promise.all(files.map(f => fs.readFile(f, 'utf-8').then(stripComments, () => '')));
19
+ // 1. Role (ui, api, infra, data, domain presets): the first template with a marker wins.
9
20
  for (const template of TEMPLATES) {
10
- const marker = await this.findFirstMarker(cwd, template.markers, true); // Search content for roles too
21
+ const marker = await this.findFirstMarker(cwd, template.markers, dependencies, contents);
11
22
  if (marker) {
12
23
  config = this.mergeConfig(config, template.config);
13
24
  matches.preset = { name: template.name, marker };
14
- break; // Only one role for now
15
- }
16
- }
17
- // 2. Detect Paradigm (oop, functional)
18
- for (const template of PARADIGM_TEMPLATES) {
19
- const marker = await this.findFirstMarker(cwd, template.markers, true); // Search content
20
- if (marker) {
21
- config = this.mergeConfig(config, template.config);
22
- matches.paradigm = { name: template.name, marker };
23
25
  break;
24
26
  }
25
27
  }
28
+ // 2. Paradigm (oop, functional): only when the declarations clearly lean one way.
29
+ const paradigm = detectParadigm(contents);
30
+ const template = paradigm && PARADIGM_TEMPLATES.find(t => t.name === paradigm.name);
31
+ if (paradigm && template) {
32
+ config = this.mergeConfig(config, template.config);
33
+ matches.paradigm = paradigm;
34
+ }
26
35
  return { config, matches };
27
36
  }
28
37
  mergeConfig(base, extension) {
@@ -47,49 +56,27 @@ export class DiscoveryService {
47
56
  ignore: [...new Set([...(base.ignore || []), ...(extension.ignore || [])])],
48
57
  };
49
58
  }
50
- async findFirstMarker(cwd, markers, searchContent = false) {
59
+ async findFirstMarker(cwd, markers, dependencies, contents) {
60
+ const keywords = [];
51
61
  for (const marker of markers) {
52
- const fullPath = path.join(cwd, marker);
53
- // File/Directory existence check
54
- if (await fs.pathExists(fullPath)) {
62
+ if (PACKAGE_MARKERS.has(marker)) {
63
+ if (declaresPackage(dependencies, marker))
64
+ return `dependency:${marker}`;
65
+ }
66
+ else if (EXTENSION_MARKERS.has(marker)) {
67
+ if (await hasFileWithExtension(cwd, marker))
68
+ return `file:*.${marker}`;
69
+ }
70
+ else if (await fs.pathExists(path.join(cwd, marker))) {
55
71
  return marker;
56
72
  }
57
- // Deep content check for paradigms
58
- if (searchContent) {
59
- const match = await this.existsInContent(cwd, marker);
60
- if (match)
61
- return `content:${marker}`;
73
+ else if (!isPathMarker(marker) && this.recurs(contents, marker)) {
74
+ keywords.push(marker);
62
75
  }
63
76
  }
64
- return null;
65
- }
66
- async existsInContent(cwd, pattern) {
67
- // Simple heuristic: search in top 5 source files
68
- const files = await this.findSourceFiles(cwd);
69
- for (const file of files) {
70
- const content = await fs.readFile(file, 'utf-8');
71
- if (content.includes(pattern))
72
- return true;
73
- }
74
- return false;
77
+ return keywords.length >= MIN_KEYWORDS ? `content:${keywords.join(',')}` : null;
75
78
  }
76
- async findSourceFiles(cwd) {
77
- const extensions = ['.ts', '.js', '.py', '.go', '.java', '.tf', 'package.json'];
78
- const samples = [];
79
- const commonDirs = ['.', 'src', 'app', 'lib', 'api', 'pkg'];
80
- for (const dir of commonDirs) {
81
- const fullDir = path.join(cwd, dir);
82
- if (!(await fs.pathExists(fullDir)))
83
- continue;
84
- const files = await fs.readdir(fullDir);
85
- for (const file of files) {
86
- if (extensions.some(ext => file.endsWith(ext))) {
87
- samples.push(path.join(fullDir, file));
88
- if (samples.length >= 5)
89
- return samples;
90
- }
91
- }
92
- }
93
- return samples;
79
+ recurs(contents, keyword) {
80
+ return contents.filter(text => containsToken(text, keyword)).length >= MIN_KEYWORD_FILES;
94
81
  }
95
82
  }
@@ -1,80 +1,93 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import fs from 'fs';
2
+ import os from 'os';
3
+ import path from 'path';
4
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2
5
  import { DiscoveryService } from './discovery.js';
3
- import fs from 'fs-extra';
4
- vi.mock('fs-extra');
6
+ import { containsToken, detectParadigm, stripComments } from './discovery-signals.js';
5
7
  describe('DiscoveryService', () => {
6
- beforeEach(() => {
7
- vi.resetAllMocks();
8
+ let cwd;
9
+ beforeEach(() => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'discovery-')); });
10
+ afterEach(() => { fs.rmSync(cwd, { recursive: true, force: true }); });
11
+ function write(rel, body) {
12
+ fs.mkdirSync(path.dirname(path.join(cwd, rel)), { recursive: true });
13
+ fs.writeFileSync(path.join(cwd, rel), body);
14
+ }
15
+ const discover = () => new DiscoveryService().discover(cwd);
16
+ it('detects an API from a declared dependency', async () => {
17
+ write('package.json', JSON.stringify({ dependencies: { express: '^4.0.0' } }));
18
+ const result = await discover();
19
+ expect(result.matches.preset).toEqual({ name: 'api', marker: 'dependency:express' });
8
20
  });
9
- it('should discover project marker in root directory', async () => {
10
- const service = new DiscoveryService();
11
- vi.mocked(fs.pathExists).mockImplementation(async (p) => p.includes('package.json'));
12
- vi.mocked(fs.readdir).mockResolvedValue(['package.json']);
13
- vi.mocked(fs.readFile).mockResolvedValue('{}');
14
- const result = await service.discover('/test');
15
- // If package.json doesn't match a specific role marker, it stays Universal.
16
- // Let's mock a specific one like 'express'
17
- vi.mocked(fs.pathExists).mockImplementation(async (p) => p.includes('express'));
18
- const result2 = await service.discover('/test');
19
- expect(result2.matches.preset?.name).toBe('api');
21
+ it('detects a scoped framework package such as @nestjs/core', async () => {
22
+ write('package.json', JSON.stringify({ dependencies: { '@nestjs/core': '^10.0.0' } }));
23
+ expect((await discover()).matches.preset?.marker).toBe('dependency:nestjs');
20
24
  });
21
- it('should discover project marker in src/ directory (Deep Detection)', async () => {
22
- const service = new DiscoveryService();
23
- vi.mocked(fs.pathExists).mockImplementation((async (p) => {
24
- if (p.endsWith('src'))
25
- return true;
26
- if (p.includes('src/index.ts'))
27
- return true;
28
- return false;
29
- }));
30
- vi.mocked(fs.readdir).mockImplementation((async (p) => {
31
- if (p.toString().endsWith('/test'))
32
- return ['src'];
33
- if (p.toString().endsWith('src'))
34
- return ['index.ts'];
35
- return [];
36
- }));
37
- vi.mocked(fs.readFile).mockResolvedValue('export const x = 1;');
38
- const result = await service.discover('/test');
39
- // Since UNIVERSAL_CONFIG has a default, we check if it found something extra or matches expectation
40
- // Default is universal, but detecting .ts should tilt it towards node or similar if configured
41
- // In our current templates, package.json is the node marker.
42
- // Let's check for paradigm detection which uses content
43
- expect(result.config).toBeDefined();
44
- });
45
- it('should identify OOP paradigm from content in subfolder', async () => {
46
- const service = new DiscoveryService();
47
- vi.mocked(fs.pathExists).mockImplementation((async (p) => p.endsWith('src') || p.endsWith('src/Service.ts')));
48
- vi.mocked(fs.readdir).mockImplementation((async (p) => {
49
- if (p.toString().endsWith('src'))
50
- return ['Service.ts'];
51
- return ['src'];
52
- }));
53
- vi.mocked(fs.readFile).mockResolvedValue('class MyService {}');
54
- const result = await service.discover('/test');
55
- expect(result.matches.paradigm?.name).toBe('oop');
56
- });
57
- it('should include project-type-aware ignore patterns for API preset', async () => {
58
- const service = new DiscoveryService();
59
- // Mock finding requirements.txt (Python API marker)
60
- vi.mocked(fs.pathExists).mockImplementation(async (p) => p.includes('requirements.txt'));
61
- vi.mocked(fs.readdir).mockResolvedValue(['requirements.txt']);
62
- vi.mocked(fs.readFile).mockResolvedValue('flask==2.0.0');
63
- const result = await service.discover('/test');
25
+ it('detects a Python API with its ignore patterns', async () => {
26
+ write('requirements.txt', 'flask==2.0.0\n');
27
+ const result = await discover();
64
28
  expect(result.matches.preset?.name).toBe('api');
65
- expect(result.config.ignore).toContain('venv/**');
66
- expect(result.config.ignore).toContain('__pycache__/**');
67
- expect(result.config.ignore).toContain('*.pyc');
29
+ expect(result.config.ignore).toEqual(expect.arrayContaining(['venv/**', '__pycache__/**', '*.pyc']));
68
30
  });
69
- it('should include project-type-aware ignore patterns for UI preset', async () => {
70
- const service = new DiscoveryService();
71
- // Mock finding next.config.js (UI marker)
72
- vi.mocked(fs.pathExists).mockImplementation(async (p) => p.includes('next.config.js'));
73
- vi.mocked(fs.readdir).mockResolvedValue(['next.config.js']);
74
- vi.mocked(fs.readFile).mockResolvedValue('module.exports = {}');
75
- const result = await service.discover('/test');
31
+ it('detects a UI project from a config file with its ignore patterns', async () => {
32
+ write('next.config.js', 'module.exports = {};\n');
33
+ const result = await discover();
76
34
  expect(result.matches.preset?.name).toBe('ui');
77
- expect(result.config.ignore).toContain('node_modules/**');
78
- expect(result.config.ignore).toContain('.next/**');
35
+ expect(result.config.ignore).toEqual(expect.arrayContaining(['node_modules/**', '.next/**']));
36
+ });
37
+ it('detects Svelte from its dependencies, not "reactivity" or "public" in comments', async () => {
38
+ write('package.json', JSON.stringify({ devDependencies: { '@sveltejs/kit': '^2.0.0', svelte: '^5.0.0' } }));
39
+ write('eslint.config.js', '// Svelte 5 rules (state_referenced_locally, a11y, reactivity)\nexport default [];\n');
40
+ write('playwright.config.ts', '// rides the public internet\nexport default {};\n');
41
+ write('src/lib/format.ts', 'export function format(v: number) { return v.toFixed(2); }\nexport const pad = (s: string) => s.padStart(2);\n');
42
+ const result = await discover();
43
+ expect(result.matches.preset).toEqual({ name: 'ui', marker: 'dependency:svelte' });
44
+ expect(result.matches.paradigm?.name).toBe('functional');
45
+ });
46
+ it('does not read a framework name in source text as a dependency', async () => {
47
+ write('src/middleware.ts', 'export function run(next: () => void) { next(); }\n');
48
+ expect((await discover()).matches.preset).toBeUndefined();
49
+ });
50
+ it('identifies OOP from class declarations in a subfolder', async () => {
51
+ write('src/Service.ts', 'export class MyService {}\nexport class OtherService {}\n');
52
+ const result = await discover();
53
+ expect(result.matches.paradigm).toEqual({ name: 'oop', marker: 'declarations: 2 classes, 0 functions' });
54
+ });
55
+ it('needs several recurring domain keywords before choosing a domain preset', async () => {
56
+ write('src/billing.ts', 'export const ledger = new Map<string, number>();\n');
57
+ write('src/report.ts', "import { ledger } from './billing';\nexport const total = () => ledger.size;\n");
58
+ expect((await discover()).matches.preset).toBeUndefined();
59
+ write('src/pay.ts', 'export function payment(ledger: Map<string, number>) { return ledger; }\n');
60
+ write('src/refund.ts', 'export function refund(payment: number) { return -payment; }\n');
61
+ expect((await discover()).matches.preset).toEqual({ name: 'fintech', marker: 'content:payment,ledger' });
62
+ });
63
+ it('needs a file marker to exist, not to be mentioned', async () => {
64
+ write('src/serve.ts', "export const page = () => 'index.html';\n");
65
+ expect((await discover()).matches.preset).toBeUndefined();
66
+ });
67
+ it('detects notebooks by file, not by the word ipynb in code', async () => {
68
+ write('src/scan.ts', "export const exts = ['.ipynb', '.py'];\n");
69
+ expect((await discover()).matches.preset).toBeUndefined();
70
+ write('notebooks/explore.ipynb', '{}');
71
+ expect((await discover()).matches.preset).toEqual({ name: 'data', marker: 'file:*.ipynb' });
72
+ });
73
+ it('leaves the paradigm unset for an empty project', async () => {
74
+ expect((await discover()).matches.paradigm).toBeUndefined();
75
+ });
76
+ });
77
+ describe('discovery signals', () => {
78
+ it('matches whole tokens only', () => {
79
+ expect(containsToken('import x from "react"', 'react')).toBe(true);
80
+ expect(containsToken('reactivity', 'react')).toBe(false);
81
+ expect(containsToken('@vue/compiler', 'vue')).toBe(false);
82
+ expect(containsToken('GET /health', 'health')).toBe(false);
83
+ });
84
+ it('drops comments but keeps URLs in strings', () => {
85
+ const text = stripComments('/* public */\nconst u = "https://x.io"; // public\n# public\n');
86
+ expect(text).not.toContain('public');
87
+ expect(text).toContain('https://x.io');
88
+ });
89
+ it('declines to guess a paradigm when neither side clearly leads', () => {
90
+ expect(detectParadigm(['export class A {}\nexport function f() {}\n'])).toBeNull();
91
+ expect(detectParadigm(['export class A {}\nexport function f() {}\nexport function g() {}\n'])?.name).toBe('functional');
79
92
  });
80
93
  });
@@ -20,6 +20,8 @@ export interface DeepGateConfig {
20
20
  maxTokens?: number;
21
21
  temperature?: number;
22
22
  timeoutMs?: number;
23
+ /** Ask intent questions at engine-proven sites in scoped reviews (deep-intent.ts). */
24
+ intentChecks?: boolean;
23
25
  onProgress?: (message: string) => void;
24
26
  }
25
27
  export type DeepRunStatus = 'ok' | 'partial' | 'error';
@@ -18,6 +18,7 @@ import { extractFacts, verifyFindings } from '../deep/index.js';
18
18
  import { runFactsPass } from '../deep/facts-pass.js';
19
19
  import { runCodePass } from '../deep/code-pass.js';
20
20
  import { verifyCodeFindings } from '../deep/code-verifier.js';
21
+ import { runIntentChecks } from './deep-intent.js';
21
22
  import { checkLocalPatterns } from '../storage/local-memory.js';
22
23
  import { isScoped } from '../utils/scope.js';
23
24
  import { Logger } from '../utils/logger.js';
@@ -63,7 +64,10 @@ export class DeepAnalysisGate extends Gate {
63
64
  ? await this.reviewCode(context.cwd, facts)
64
65
  : await this.analyzeFacts(context.cwd, limitFiles(facts, this.config));
65
66
  this.config.onProgress?.(` ✓ ${verified.length} verified findings in ${((Date.now() - startTime) / 1000).toFixed(1)}s`);
66
- return verified.map(f => this.toFailure(f));
67
+ const intent = scoped && this.config.intentChecks
68
+ ? await runIntentChecks(context.cwd, facts.map(f => f.path), this.provider, this.config.onProgress)
69
+ : [];
70
+ return [...verified.map(f => this.toFailure(f)), ...intent];
67
71
  }
68
72
  catch (error) {
69
73
  this.outcome.status = 'error';
@@ -0,0 +1,3 @@
1
+ import type { InferenceProvider } from '../inference/types.js';
2
+ import type { Failure } from '../types/index.js';
3
+ export declare function runIntentChecks(cwd: string, files: string[], provider: InferenceProvider, onProgress?: (message: string) => void): Promise<Failure[]>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Intent checks in a scoped deep review (`rigour review --deep`,
3
+ * `rigour check <paths> --deep`): the only questions put to the model are
4
+ * ones the code cannot answer, at sites the engine has already proven.
5
+ *
6
+ * Today that is optional-read-no-fallback: at an `await Promise.all([...])`
7
+ * with no failure handling, is one read optional while another is required?
8
+ * Off unless `deep.intent_checks` is set; see docs/DEEP_ANALYSIS.md for the
9
+ * measured accuracy that decides the default.
10
+ */
11
+ import path from 'path';
12
+ import { loadProjectConfig, programBatches } from '../semantic/program.js';
13
+ import { findParallelReads } from '../semantic/intent/parallel-reads.js';
14
+ import { classifyReads, findingsFor, yesNoModel } from '../semantic/intent/intent-check.js';
15
+ /** Each read costs two model calls; bound the cost of one review. */
16
+ const MAX_SITES = 10;
17
+ const SOURCE = /\.(?:[cm]?[jt]s|[jt]sx)$/i;
18
+ export async function runIntentChecks(cwd, files, provider, onProgress) {
19
+ const sites = findSites(cwd, files.filter(f => SOURCE.test(f) && !/\.d\.[cm]?ts$/i.test(f)));
20
+ if (sites.length === 0)
21
+ return [];
22
+ const checked = sites.slice(0, MAX_SITES);
23
+ onProgress?.(` Asking the model about ${checked.length} Promise.all site(s)${sites.length > MAX_SITES ? ` (first ${MAX_SITES} of ${sites.length})` : ''}...`);
24
+ const model = yesNoModel(provider);
25
+ const failures = [];
26
+ for (const site of checked) {
27
+ failures.push(...findingsFor(site, await classifyReads(model, site)).map(toFailure));
28
+ }
29
+ return failures;
30
+ }
31
+ function findSites(cwd, files) {
32
+ const roots = files.map(f => path.resolve(cwd, f));
33
+ const wanted = new Set(roots.map(r => path.normalize(r)));
34
+ const sites = [];
35
+ for (const program of programBatches(roots, loadProjectConfig(cwd).options)) {
36
+ for (const sourceFile of program.getSourceFiles()) {
37
+ if (wanted.has(path.normalize(sourceFile.fileName)))
38
+ sites.push(...findParallelReads(cwd, program.getTypeChecker(), sourceFile));
39
+ }
40
+ }
41
+ return sites;
42
+ }
43
+ function toFailure(finding) {
44
+ return {
45
+ id: 'deep-analysis',
46
+ title: `[${finding.rule}] ${finding.message.split('. ')[0]}`.slice(0, 120),
47
+ details: `${finding.message}\n${finding.evidence.map(e => ` at ${e}`).join('\n')}`,
48
+ files: [finding.file],
49
+ line: finding.line,
50
+ hint: finding.hint,
51
+ severity: finding.severity,
52
+ provenance: finding.provenance,
53
+ category: finding.rule,
54
+ source: 'hybrid',
55
+ verified: false,
56
+ };
57
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,51 @@
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 { runIntentChecks } from './deep-intent.js';
6
+ const DASHBOARD = [
7
+ 'declare function getAccount(id: string): Promise<{ name: string }>;',
8
+ 'declare function getRecommendedCourses(id: string): Promise<string[]>;',
9
+ 'export async function loadDashboard(id: string) {',
10
+ ' const [account, recommended] = await Promise.all([getAccount(id), getRecommendedCourses(id)]);',
11
+ ' return { account, recommended };',
12
+ '}',
13
+ ].join('\n');
14
+ /** Answers as if getRecommendedCourses were optional and getAccount required. */
15
+ function provider(calls) {
16
+ return {
17
+ name: 'fake',
18
+ isAvailable: async () => true,
19
+ setup: async () => { },
20
+ dispose: () => { },
21
+ analyze: async (prompt) => {
22
+ calls.push(prompt);
23
+ const optional = /Question: [^\n]*getRecommendedCourses/.test(prompt);
24
+ const ifFails = prompt.includes('Question: If');
25
+ return JSON.stringify({ answer: ifFails === optional ? 'yes' : 'no' });
26
+ },
27
+ };
28
+ }
29
+ describe('runIntentChecks', () => {
30
+ let cwd;
31
+ beforeEach(() => { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'deep-intent-')); });
32
+ afterEach(() => { fs.rmSync(cwd, { recursive: true, force: true }); });
33
+ it('reports an optional read that can take down a required one', async () => {
34
+ fs.writeFileSync(path.join(cwd, 'dashboard.ts'), DASHBOARD);
35
+ const calls = [];
36
+ const [failure, ...rest] = await runIntentChecks(cwd, ['dashboard.ts'], provider(calls));
37
+ expect(rest).toHaveLength(0);
38
+ expect(calls).toHaveLength(4);
39
+ expect(failure).toMatchObject({
40
+ id: 'deep-analysis', files: ['dashboard.ts'], line: 4, category: 'optional-read-no-fallback',
41
+ provenance: 'deep-analysis', source: 'hybrid', verified: false,
42
+ });
43
+ expect(failure.details).toContain('`getRecommendedCourses` looks optional');
44
+ });
45
+ it('asks nothing when no file has an unhandled Promise.all', async () => {
46
+ fs.writeFileSync(path.join(cwd, 'plain.ts'), 'export const x = 1;\n');
47
+ const calls = [];
48
+ expect(await runIntentChecks(cwd, ['plain.ts', 'README.md'], provider(calls))).toEqual([]);
49
+ expect(calls).toEqual([]);
50
+ });
51
+ });
@@ -18,6 +18,7 @@ export async function runDeepAnalysis(config, context, deepOptions) {
18
18
  maxTokens: config.gates.deep?.max_tokens,
19
19
  temperature: config.gates.deep?.temperature,
20
20
  timeoutMs: config.gates.deep?.timeout_ms,
21
+ intentChecks: config.gates.deep?.intent_checks,
21
22
  onProgress: deepOptions.onProgress,
22
23
  });
23
24
  let findings = [];
@@ -0,0 +1,8 @@
1
+ export interface AstDeprecation {
2
+ line: number;
3
+ api: string;
4
+ reason: string;
5
+ replacement: string;
6
+ category: 'removed';
7
+ }
8
+ export declare function findWithStatements(content: string, file: string): AstDeprecation[];
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Deprecated JS/TS constructs that need the syntax tree to detect reliably.
3
+ *
4
+ * A `with` statement is found from the parsed tree, so the words "with (" in
5
+ * a comment, a string or an identifier never match.
6
+ */
7
+ import ts from 'typescript';
8
+ export function findWithStatements(content, file) {
9
+ const source = ts.createSourceFile(file, content, ts.ScriptTarget.Latest, true);
10
+ const found = [];
11
+ const visit = (node) => {
12
+ if (node.kind === ts.SyntaxKind.WithStatement) {
13
+ found.push({
14
+ line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
15
+ api: 'with statement',
16
+ reason: 'Removed in strict mode, creates ambiguous scope, security risk',
17
+ replacement: 'Destructuring or explicit property access',
18
+ category: 'removed',
19
+ });
20
+ }
21
+ ts.forEachChild(node, visit);
22
+ };
23
+ visit(source);
24
+ return found;
25
+ }
@@ -15,7 +15,8 @@ export const NODE_DEPRECATED_RULES = [
15
15
  category: 'security',
16
16
  },
17
17
  {
18
- pattern: /Buffer\s*\(\s*(?:\d|['"])/,
18
+ // Not ArrayBuffer(n) / SharedArrayBuffer(n) / x.Buffer(...).
19
+ pattern: /(?<![\w$.])Buffer\s*\(\s*(?:\d|['"])/,
19
20
  api: 'Buffer() constructor',
20
21
  reason: 'DEP0005: Security vulnerability — uninitialized memory exposure',
21
22
  replacement: 'Buffer.from(), Buffer.alloc(), or Buffer.allocUnsafe()',
@@ -168,13 +169,6 @@ export const WEB_DEPRECATED_RULES = [
168
169
  replacement: 'JSON.parse(), Function constructor (if absolutely needed), or structured approach',
169
170
  category: 'security',
170
171
  },
171
- {
172
- pattern: /with\s*\(\s*\w/,
173
- api: 'with statement',
174
- reason: 'Removed in strict mode, creates ambiguous scope, security risk',
175
- replacement: 'Destructuring or explicit property access',
176
- category: 'removed',
177
- },
178
172
  {
179
173
  pattern: /document\.all\b/,
180
174
  api: 'document.all',