@rigour-labs/core 6.2.4 → 6.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +2 -0
  2. package/dist/deep/code-context.d.ts +28 -0
  3. package/dist/deep/code-context.js +93 -0
  4. package/dist/deep/code-context.test.d.ts +1 -0
  5. package/dist/deep/code-context.test.js +48 -0
  6. package/dist/deep/code-pass.d.ts +15 -0
  7. package/dist/deep/code-pass.js +44 -0
  8. package/dist/deep/code-review-prompt.d.ts +17 -0
  9. package/dist/deep/code-review-prompt.js +30 -0
  10. package/dist/deep/code-review-prompt.test.d.ts +1 -0
  11. package/dist/deep/code-review-prompt.test.js +20 -0
  12. package/dist/deep/code-verifier.d.ts +19 -0
  13. package/dist/deep/code-verifier.js +48 -0
  14. package/dist/deep/code-verifier.test.d.ts +1 -0
  15. package/dist/deep/code-verifier.test.js +46 -0
  16. package/dist/deep/fact-extractor.d.ts +7 -1
  17. package/dist/deep/fact-extractor.js +16 -8
  18. package/dist/deep/fact-extractor.test.js +34 -1
  19. package/dist/deep/facts-pass.d.ts +24 -0
  20. package/dist/deep/facts-pass.js +69 -0
  21. package/dist/deep/index.d.ts +6 -1
  22. package/dist/deep/index.js +5 -1
  23. package/dist/deep/parse-findings.d.ts +12 -0
  24. package/dist/deep/parse-findings.js +73 -0
  25. package/dist/deep/parse-findings.test.d.ts +1 -0
  26. package/dist/deep/parse-findings.test.js +25 -0
  27. package/dist/deep/prompts.d.ts +1 -1
  28. package/dist/deep/prompts.js +7 -8
  29. package/dist/deep/prompts.test.js +15 -0
  30. package/dist/discovery-signals.d.ts +27 -0
  31. package/dist/discovery-signals.js +96 -0
  32. package/dist/discovery.d.ts +1 -2
  33. package/dist/discovery.js +36 -49
  34. package/dist/discovery.test.js +84 -71
  35. package/dist/gates/context.js +7 -0
  36. package/dist/gates/deep-analysis.d.ts +33 -8
  37. package/dist/gates/deep-analysis.js +169 -306
  38. package/dist/gates/deep-analysis.test.d.ts +1 -0
  39. package/dist/gates/deep-analysis.test.js +86 -0
  40. package/dist/gates/deep-intent.d.ts +3 -0
  41. package/dist/gates/deep-intent.js +57 -0
  42. package/dist/gates/deep-intent.test.d.ts +1 -0
  43. package/dist/gates/deep-intent.test.js +51 -0
  44. package/dist/gates/deep-runner.d.ts +15 -0
  45. package/dist/gates/deep-runner.js +66 -0
  46. package/dist/gates/dependency.js +4 -0
  47. package/dist/gates/deprecated-apis-ast.d.ts +8 -0
  48. package/dist/gates/deprecated-apis-ast.js +25 -0
  49. package/dist/gates/deprecated-apis-rules-node.js +2 -8
  50. package/dist/gates/deprecated-apis.js +5 -1
  51. package/dist/gates/deprecated-apis.test.js +30 -0
  52. package/dist/gates/frontend-secret-exposure.js +5 -1
  53. package/dist/gates/frontend-secret-exposure.test.js +20 -0
  54. package/dist/gates/hallucinated-imports/framework-modules.d.ts +14 -0
  55. package/dist/gates/hallucinated-imports/framework-modules.js +75 -0
  56. package/dist/gates/hallucinated-imports/index.js +14 -6
  57. package/dist/gates/hallucinated-imports/js-resolver.d.ts +16 -12
  58. package/dist/gates/hallucinated-imports/js-resolver.js +65 -153
  59. package/dist/gates/hallucinated-imports/ts-path-target.js +16 -0
  60. package/dist/gates/hallucinated-imports/tsconfig-paths.d.ts +16 -0
  61. package/dist/gates/hallucinated-imports/tsconfig-paths.js +120 -0
  62. package/dist/gates/hallucinated-imports-sveltekit.test.d.ts +1 -0
  63. package/dist/gates/hallucinated-imports-sveltekit.test.js +108 -0
  64. package/dist/gates/logic-drift.js +7 -1
  65. package/dist/gates/runner.js +10 -40
  66. package/dist/gates/runner.test.js +37 -25
  67. package/dist/gates/scoped-gates.test.d.ts +1 -0
  68. package/dist/gates/scoped-gates.test.js +52 -0
  69. package/dist/gates/semantic-bugs.d.ts +23 -0
  70. package/dist/gates/semantic-bugs.js +48 -0
  71. package/dist/gates/semantic-bugs.test.d.ts +1 -0
  72. package/dist/gates/semantic-bugs.test.js +70 -0
  73. package/dist/gates/side-effect-analysis/index.js +11 -1
  74. package/dist/gates/side-effect-analysis.test.d.ts +1 -0
  75. package/dist/gates/side-effect-analysis.test.js +162 -0
  76. package/dist/gates/side-effect-helpers/pattern-detection.js +35 -7
  77. package/dist/gates/side-effect-helpers/resource-tracking.js +17 -10
  78. package/dist/gates/side-effect-helpers/scope-analysis.d.ts +6 -0
  79. package/dist/gates/side-effect-helpers/scope-analysis.js +20 -0
  80. package/dist/gates/style-drift.js +3 -0
  81. package/dist/index.d.ts +11 -2
  82. package/dist/index.js +10 -2
  83. package/dist/inference/http-download.d.ts +22 -0
  84. package/dist/inference/http-download.js +132 -0
  85. package/dist/inference/http-download.test.d.ts +1 -0
  86. package/dist/inference/http-download.test.js +109 -0
  87. package/dist/inference/index.d.ts +5 -1
  88. package/dist/inference/index.js +2 -1
  89. package/dist/inference/llama-engine.d.ts +27 -0
  90. package/dist/inference/llama-engine.js +152 -0
  91. package/dist/inference/llama-engine.test.d.ts +1 -0
  92. package/dist/inference/llama-engine.test.js +51 -0
  93. package/dist/inference/llama-process.d.ts +72 -0
  94. package/dist/inference/llama-process.js +126 -0
  95. package/dist/inference/llama-process.test.d.ts +1 -0
  96. package/dist/inference/llama-process.test.js +61 -0
  97. package/dist/inference/model-download.test.d.ts +1 -0
  98. package/dist/inference/model-download.test.js +73 -0
  99. package/dist/inference/model-manager.d.ts +14 -1
  100. package/dist/inference/model-manager.js +65 -80
  101. package/dist/inference/sidecar-provider.d.ts +19 -10
  102. package/dist/inference/sidecar-provider.js +125 -342
  103. package/dist/inference/types.d.ts +2 -0
  104. package/dist/semantic/ast.d.ts +29 -0
  105. package/dist/semantic/ast.js +122 -0
  106. package/dist/semantic/benchmark.d.ts +16 -0
  107. package/dist/semantic/benchmark.js +31 -0
  108. package/dist/semantic/benchmark.test.d.ts +1 -0
  109. package/dist/semantic/benchmark.test.js +18 -0
  110. package/dist/semantic/engine.d.ts +8 -0
  111. package/dist/semantic/engine.js +45 -0
  112. package/dist/semantic/intent/benchmark.d.ts +31 -0
  113. package/dist/semantic/intent/benchmark.js +74 -0
  114. package/dist/semantic/intent/intent-check.d.ts +43 -0
  115. package/dist/semantic/intent/intent-check.js +72 -0
  116. package/dist/semantic/intent/intent.test.d.ts +1 -0
  117. package/dist/semantic/intent/intent.test.js +46 -0
  118. package/dist/semantic/intent/parallel-reads.d.ts +23 -0
  119. package/dist/semantic/intent/parallel-reads.js +59 -0
  120. package/dist/semantic/learn/benchmark.d.ts +20 -0
  121. package/dist/semantic/learn/benchmark.js +71 -0
  122. package/dist/semantic/learn/compile.d.ts +4 -0
  123. package/dist/semantic/learn/compile.js +56 -0
  124. package/dist/semantic/learn/conditions.d.ts +22 -0
  125. package/dist/semantic/learn/conditions.js +93 -0
  126. package/dist/semantic/learn/extract.d.ts +30 -0
  127. package/dist/semantic/learn/extract.js +122 -0
  128. package/dist/semantic/learn/git-trees.d.ts +8 -0
  129. package/dist/semantic/learn/git-trees.js +32 -0
  130. package/dist/semantic/learn/identity.d.ts +18 -0
  131. package/dist/semantic/learn/identity.js +97 -0
  132. package/dist/semantic/learn/learn.d.ts +25 -0
  133. package/dist/semantic/learn/learn.js +109 -0
  134. package/dist/semantic/learn/learn.test.d.ts +1 -0
  135. package/dist/semantic/learn/learn.test.js +67 -0
  136. package/dist/semantic/learn/store.d.ts +4 -0
  137. package/dist/semantic/learn/store.js +39 -0
  138. package/dist/semantic/learn/types.d.ts +468 -0
  139. package/dist/semantic/learn/types.js +55 -0
  140. package/dist/semantic/origins.d.ts +42 -0
  141. package/dist/semantic/origins.js +149 -0
  142. package/dist/semantic/origins.test.d.ts +1 -0
  143. package/dist/semantic/origins.test.js +77 -0
  144. package/dist/semantic/program.d.ts +13 -0
  145. package/dist/semantic/program.js +62 -0
  146. package/dist/semantic/rules/credential-redirect.d.ts +2 -0
  147. package/dist/semantic/rules/credential-redirect.js +209 -0
  148. package/dist/semantic/rules/degraded-response-cached.d.ts +2 -0
  149. package/dist/semantic/rules/degraded-response-cached.js +271 -0
  150. package/dist/semantic/rules/in-memory-aggregation.d.ts +2 -0
  151. package/dist/semantic/rules/in-memory-aggregation.js +179 -0
  152. package/dist/semantic/summaries.d.ts +18 -0
  153. package/dist/semantic/summaries.js +33 -0
  154. package/dist/semantic/types.d.ts +32 -0
  155. package/dist/semantic/types.js +1 -0
  156. package/dist/storage/index.d.ts +1 -0
  157. package/dist/storage/index.js +1 -0
  158. package/dist/storage/team-diagnostics.d.ts +22 -0
  159. package/dist/storage/team-diagnostics.js +58 -0
  160. package/dist/storage/team-diagnostics.test.d.ts +1 -0
  161. package/dist/storage/team-diagnostics.test.js +51 -0
  162. package/dist/storage/team-schema.d.ts +6 -0
  163. package/dist/storage/team-schema.js +82 -0
  164. package/dist/storage/team-store.js +25 -72
  165. package/dist/templates/universal-config.js +4 -2
  166. package/dist/types/index.d.ts +114 -14
  167. package/dist/types/index.js +16 -2
  168. package/dist/utils/deep-status.d.ts +7 -0
  169. package/dist/utils/deep-status.js +11 -0
  170. package/dist/utils/diff.d.ts +3 -0
  171. package/dist/utils/diff.js +52 -0
  172. package/dist/utils/diff.test.d.ts +1 -0
  173. package/dist/utils/diff.test.js +38 -0
  174. package/dist/utils/scanner.js +13 -2
  175. package/dist/utils/scanner.test.js +22 -8
  176. package/dist/utils/scope.d.ts +15 -0
  177. package/dist/utils/scope.js +51 -0
  178. package/dist/utils/scope.test.d.ts +1 -0
  179. package/dist/utils/scope.test.js +38 -0
  180. package/package.json +6 -6
@@ -7,8 +7,13 @@
7
7
  *
8
8
  * Neither AST nor LLM works alone. Together they're accurate.
9
9
  */
10
- export { extractFacts, factsToPromptString } from './fact-extractor.js';
10
+ export { extractFacts, factsToPromptString, isTestFile } from './fact-extractor.js';
11
11
  export type { FileFacts, ClassFact, FunctionFact, ErrorHandlingFact, StructFact, InterfaceFact } from './fact-extractor.js';
12
12
  export { buildAnalysisPrompt, buildCrossFilePrompt, chunkFacts, DEEP_SYSTEM_PROMPT } from './prompts.js';
13
13
  export { verifyFindings } from './verifier.js';
14
14
  export type { VerifiedFinding } from './verifier.js';
15
+ export { parseFindings } from './parse-findings.js';
16
+ export { buildCodeContext } from './code-context.js';
17
+ export type { CodeContext, LineRange } from './code-context.js';
18
+ export { buildCodeReviewPrompt, REVIEW_CATEGORIES } from './code-review-prompt.js';
19
+ export { verifyCodeFindings } from './code-verifier.js';
@@ -7,6 +7,10 @@
7
7
  *
8
8
  * Neither AST nor LLM works alone. Together they're accurate.
9
9
  */
10
- export { extractFacts, factsToPromptString } from './fact-extractor.js';
10
+ export { extractFacts, factsToPromptString, isTestFile } from './fact-extractor.js';
11
11
  export { buildAnalysisPrompt, buildCrossFilePrompt, chunkFacts, DEEP_SYSTEM_PROMPT } from './prompts.js';
12
12
  export { verifyFindings } from './verifier.js';
13
+ export { parseFindings } from './parse-findings.js';
14
+ export { buildCodeContext } from './code-context.js';
15
+ export { buildCodeReviewPrompt, REVIEW_CATEGORIES } from './code-review-prompt.js';
16
+ export { verifyCodeFindings } from './code-verifier.js';
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Parse an LLM response into structured findings.
3
+ *
4
+ * Handles raw JSON, markdown-wrapped JSON, a findings object embedded in
5
+ * prose, and truncated responses (the model hit its token budget mid-array).
6
+ */
7
+ import type { DeepFinding } from '../inference/types.js';
8
+ export declare function parseFindings(response: string): DeepFinding[];
9
+ /** Extract the complete finding objects from a response cut off mid-array. */
10
+ export declare function recoverTruncatedFindings(response: string): DeepFinding[];
11
+ /** Drop malformed entries and normalise confidence and severity. */
12
+ export declare function validateFindings(raw: unknown[]): DeepFinding[];
@@ -0,0 +1,73 @@
1
+ import { Logger } from '../utils/logger.js';
2
+ const VALID_SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
3
+ const FINDING_OBJECT = /\{\s*"category"\s*:\s*"[^"]+"\s*,[\s\S]*?"description"\s*:\s*"[^"]*"[^}]*\}/g;
4
+ export function parseFindings(response) {
5
+ if (!response || response.trim().length === 0) {
6
+ Logger.warn('Empty LLM response received');
7
+ return [];
8
+ }
9
+ const candidates = [
10
+ response,
11
+ response.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1],
12
+ response.match(/\{[\s\S]*"findings"[\s\S]*\}/)?.[0],
13
+ ];
14
+ for (const candidate of candidates) {
15
+ const parsed = candidate === undefined ? null : tryParseFindings(candidate);
16
+ if (parsed)
17
+ return parsed;
18
+ }
19
+ const recovered = recoverTruncatedFindings(response);
20
+ if (recovered.length > 0) {
21
+ Logger.info(`Recovered ${recovered.length} findings from truncated response`);
22
+ return recovered;
23
+ }
24
+ Logger.warn(`Could not parse LLM response as findings JSON. First 200 chars: ${response.substring(0, 200)}`);
25
+ return [];
26
+ }
27
+ function tryParseFindings(text) {
28
+ try {
29
+ const parsed = JSON.parse(text);
30
+ if (Array.isArray(parsed?.findings))
31
+ return validateFindings(parsed.findings);
32
+ if (Array.isArray(parsed))
33
+ return validateFindings(parsed);
34
+ return [];
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ /** Extract the complete finding objects from a response cut off mid-array. */
41
+ export function recoverTruncatedFindings(response) {
42
+ const findings = [];
43
+ for (const match of response.matchAll(FINDING_OBJECT)) {
44
+ try {
45
+ findings.push(JSON.parse(match[0]));
46
+ }
47
+ catch {
48
+ // The object itself was truncated; skip it.
49
+ }
50
+ }
51
+ return validateFindings(findings);
52
+ }
53
+ /** Drop malformed entries and normalise confidence and severity. */
54
+ export function validateFindings(raw) {
55
+ return raw.filter(hasRequiredFields).map(normalizeFinding);
56
+ }
57
+ function hasRequiredFields(f) {
58
+ return !!f && typeof f === 'object'
59
+ && isNonEmptyString(f.category)
60
+ && isNonEmptyString(f.file)
61
+ && isNonEmptyString(f.description);
62
+ }
63
+ function isNonEmptyString(value) {
64
+ return typeof value === 'string' && value.length > 0;
65
+ }
66
+ function normalizeFinding(f) {
67
+ const confidenceOk = typeof f.confidence === 'number' && f.confidence >= 0 && f.confidence <= 1;
68
+ return {
69
+ ...f,
70
+ confidence: confidenceOk ? f.confidence : 0.5,
71
+ severity: VALID_SEVERITIES.includes(f.severity) ? f.severity : 'medium',
72
+ };
73
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseFindings } from './parse-findings.js';
3
+ const finding = { category: 'correctness', severity: 'high', file: 'a.ts', line: 3, description: 'bug', suggestion: 'fix', confidence: 0.8 };
4
+ describe('parseFindings', () => {
5
+ it('parses a findings object, a bare array and fenced JSON', () => {
6
+ expect(parseFindings(JSON.stringify({ findings: [finding] }))).toHaveLength(1);
7
+ expect(parseFindings(JSON.stringify([finding]))).toHaveLength(1);
8
+ expect(parseFindings('Here you go:\n```json\n' + JSON.stringify({ findings: [finding] }) + '\n```')).toHaveLength(1);
9
+ });
10
+ it('recovers complete findings from a response cut off by the token budget', () => {
11
+ const cut = `{"findings": [${JSON.stringify(finding)}, {"category": "security", "severity": "hi`;
12
+ expect(parseFindings(cut)).toEqual([finding]);
13
+ });
14
+ it('drops malformed entries and normalises confidence and severity', () => {
15
+ const parsed = parseFindings(JSON.stringify({ findings: [
16
+ { ...finding, severity: 'urgent', confidence: 7 },
17
+ { category: 'x', description: 'no file' },
18
+ ] }));
19
+ expect(parsed).toEqual([{ ...finding, severity: 'medium', confidence: 0.5 }]);
20
+ });
21
+ it('returns nothing for empty or non-JSON output', () => {
22
+ expect(parseFindings('')).toEqual([]);
23
+ expect(parseFindings('I could not find any issues.')).toEqual([]);
24
+ });
25
+ });
@@ -2,7 +2,7 @@
2
2
  * Prompt Engineering — Step 2 of the three-step pipeline.
3
3
  * Constructs structured prompts that ask the LLM to interpret AST-extracted facts.
4
4
  */
5
- import type { FileFacts } from './fact-extractor.js';
5
+ import { type FileFacts } from './fact-extractor.js';
6
6
  /**
7
7
  * System prompt that defines the LLM's role and output format.
8
8
  */
@@ -1,3 +1,8 @@
1
+ /**
2
+ * Prompt Engineering — Step 2 of the three-step pipeline.
3
+ * Constructs structured prompts that ask the LLM to interpret AST-extracted facts.
4
+ */
5
+ import { factsToPromptString } from './fact-extractor.js';
1
6
  /**
2
7
  * System prompt that defines the LLM's role and output format.
3
8
  */
@@ -362,13 +367,7 @@ export function chunkFacts(facts, maxCharsPerChunk = 6000) {
362
367
  }
363
368
  return chunks;
364
369
  }
370
+ /** Serialized size of one file's facts, so chunks never overrun the prompt budget. */
365
371
  function estimateFactSize(f) {
366
- let size = f.path.length + 50;
367
- size += f.classes.reduce((a, c) => a + c.name.length + c.methods.length * 20 + 50, 0);
368
- size += (f.structs || []).reduce((a, s) => a + s.name.length + s.methods.length * 20 + s.embeds.length * 15 + 60, 0);
369
- size += (f.interfaces || []).reduce((a, i) => a + i.name.length + i.methods.length * 15 + 40, 0);
370
- size += f.functions.reduce((a, fn) => a + fn.name.length + fn.params.length * 15 + 50, 0);
371
- size += f.imports.length * 30;
372
- size += f.errorHandling.length * 30;
373
- return size;
372
+ return factsToPromptString([f], Number.POSITIVE_INFINITY).length + 2;
374
373
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import { buildAnalysisPrompt, buildCrossFilePrompt, chunkFacts, DEEP_SYSTEM_PROMPT } from './prompts.js';
3
+ import { factsToPromptString } from './fact-extractor.js';
3
4
  // ── Test helpers ──
4
5
  function makeGoFacts(overrides = {}) {
5
6
  return {
@@ -195,6 +196,20 @@ describe('Deep Analysis Prompts', () => {
195
196
  });
196
197
  // ── chunkFacts ──
197
198
  describe('chunkFacts', () => {
199
+ it('never lets the serialized chunk overrun the prompt budget, so no file is dropped', () => {
200
+ const facts = Array.from({ length: 40 }, (_, i) => makeTsFacts({
201
+ path: `src/deeply/nested/module-${i}/implementation-file-with-a-long-name-${i}.ts`,
202
+ lineCount: 300,
203
+ imports: Array.from({ length: 8 }, (_, j) => `@scope/package-with-long-name-${j}/sub/path`),
204
+ }));
205
+ const chunks = chunkFacts(facts, 6000);
206
+ expect(chunks.flat()).toHaveLength(facts.length);
207
+ for (const chunk of chunks) {
208
+ const text = factsToPromptString(chunk);
209
+ for (const f of chunk)
210
+ expect(text).toContain(`FILE: ${f.path}`);
211
+ }
212
+ });
198
213
  it('should split facts into token-limited chunks', () => {
199
214
  const manyFacts = Array.from({ length: 50 }, (_, i) => makeTsFacts({
200
215
  path: `src/file${i}.ts`,
@@ -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
  });
@@ -1,5 +1,6 @@
1
1
  import { Gate } from './base.js';
2
2
  import { FileScanner } from '../utils/scanner.js';
3
+ import { isScoped, resolveScopedFiles, touchesScope } from '../utils/scope.js';
3
4
  import fs from 'fs-extra';
4
5
  import path from 'path';
5
6
  export class ContextGate extends Gate {
@@ -57,6 +58,12 @@ export class ContextGate extends Gate {
57
58
  if (this.extendedConfig.import_relationships) {
58
59
  this.analyzeImportPatterns(importPatterns, failures);
59
60
  }
61
+ // Cross-file patterns need the whole repo, but a scoped run reports
62
+ // only findings that cite a file in scope.
63
+ if (isScoped(context.patterns)) {
64
+ const scoped = await resolveScopedFiles(context.cwd, context.patterns, context.ignore);
65
+ return failures.filter(f => touchesScope(f.files, scoped));
66
+ }
60
67
  return failures;
61
68
  }
62
69
  checkEnvDrift(content, file, anchors, failures) {