@euconform/core 1.2.0 → 1.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 (121) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +6 -0
  3. package/dist/evidence/bundle.d.ts +23 -0
  4. package/dist/evidence/bundle.d.ts.map +1 -0
  5. package/dist/evidence/bundle.js +44 -0
  6. package/dist/evidence/compliance-evaluation.d.ts +12 -0
  7. package/dist/evidence/compliance-evaluation.d.ts.map +1 -0
  8. package/dist/evidence/compliance-evaluation.js +78 -0
  9. package/dist/evidence/hash.d.ts +2 -0
  10. package/dist/evidence/hash.d.ts.map +1 -0
  11. package/dist/evidence/hash.js +4 -0
  12. package/dist/evidence/index.d.ts +13 -0
  13. package/dist/evidence/index.d.ts.map +1 -0
  14. package/dist/evidence/index.js +11 -0
  15. package/dist/evidence/markdown.d.ts +9 -0
  16. package/dist/evidence/markdown.d.ts.map +1 -0
  17. package/dist/evidence/markdown.js +169 -0
  18. package/dist/evidence/output.d.ts +13 -0
  19. package/dist/evidence/output.d.ts.map +1 -0
  20. package/dist/evidence/output.js +547 -0
  21. package/dist/evidence/types.d.ts +230 -0
  22. package/dist/evidence/types.d.ts.map +1 -0
  23. package/dist/evidence/types.js +7 -0
  24. package/dist/evidence/validate.d.ts +8 -0
  25. package/dist/evidence/validate.d.ts.map +1 -0
  26. package/dist/evidence/validate.js +175 -0
  27. package/dist/inference/ollama-client.d.ts +2 -0
  28. package/dist/inference/ollama-client.d.ts.map +1 -1
  29. package/dist/inference/ollama-client.js +61 -73
  30. package/dist/scanner/aggregator.d.ts +7 -0
  31. package/dist/scanner/aggregator.d.ts.map +1 -0
  32. package/dist/scanner/aggregator.js +200 -0
  33. package/dist/scanner/detectors/ai-framework-detector.d.ts +3 -0
  34. package/dist/scanner/detectors/ai-framework-detector.d.ts.map +1 -0
  35. package/dist/scanner/detectors/ai-framework-detector.js +74 -0
  36. package/dist/scanner/detectors/ai-provider-detector.d.ts +3 -0
  37. package/dist/scanner/detectors/ai-provider-detector.d.ts.map +1 -0
  38. package/dist/scanner/detectors/ai-provider-detector.js +100 -0
  39. package/dist/scanner/detectors/compliance-detector.d.ts +3 -0
  40. package/dist/scanner/detectors/compliance-detector.d.ts.map +1 -0
  41. package/dist/scanner/detectors/compliance-detector.js +201 -0
  42. package/dist/scanner/detectors/config-detector.d.ts +3 -0
  43. package/dist/scanner/detectors/config-detector.d.ts.map +1 -0
  44. package/dist/scanner/detectors/config-detector.js +248 -0
  45. package/dist/scanner/detectors/framework-detector.d.ts +3 -0
  46. package/dist/scanner/detectors/framework-detector.d.ts.map +1 -0
  47. package/dist/scanner/detectors/framework-detector.js +141 -0
  48. package/dist/scanner/detectors/index.d.ts +8 -0
  49. package/dist/scanner/detectors/index.d.ts.map +1 -0
  50. package/dist/scanner/detectors/index.js +37 -0
  51. package/dist/scanner/detectors/local-inference-detector.d.ts +3 -0
  52. package/dist/scanner/detectors/local-inference-detector.d.ts.map +1 -0
  53. package/dist/scanner/detectors/local-inference-detector.js +93 -0
  54. package/dist/scanner/detectors/markdown-detector.d.ts +3 -0
  55. package/dist/scanner/detectors/markdown-detector.d.ts.map +1 -0
  56. package/dist/scanner/detectors/markdown-detector.js +139 -0
  57. package/dist/scanner/detectors/package-json-detector.d.ts +3 -0
  58. package/dist/scanner/detectors/package-json-detector.d.ts.map +1 -0
  59. package/dist/scanner/detectors/package-json-detector.js +166 -0
  60. package/dist/scanner/detectors/rag-detector.d.ts +3 -0
  61. package/dist/scanner/detectors/rag-detector.d.ts.map +1 -0
  62. package/dist/scanner/detectors/rag-detector.js +132 -0
  63. package/dist/scanner/detectors/shared.d.ts +18 -0
  64. package/dist/scanner/detectors/shared.d.ts.map +1 -0
  65. package/dist/scanner/detectors/shared.js +36 -0
  66. package/dist/scanner/discovery.d.ts +14 -0
  67. package/dist/scanner/discovery.d.ts.map +1 -0
  68. package/dist/scanner/discovery.js +235 -0
  69. package/dist/scanner/index.d.ts +9 -0
  70. package/dist/scanner/index.d.ts.map +1 -0
  71. package/dist/scanner/index.js +8 -0
  72. package/package.json +6 -3
  73. package/src/evidence/bundle.ts +65 -0
  74. package/src/evidence/compliance-evaluation.ts +114 -0
  75. package/src/evidence/hash.ts +5 -0
  76. package/src/evidence/index.ts +51 -0
  77. package/src/evidence/markdown.ts +226 -0
  78. package/src/evidence/output.ts +717 -0
  79. package/src/evidence/types.ts +329 -0
  80. package/src/evidence/validate.ts +270 -0
  81. package/src/inference/ollama-client.ts +81 -90
  82. package/src/scanner/aggregator.ts +258 -0
  83. package/src/scanner/detectors/ai-framework-detector.ts +94 -0
  84. package/src/scanner/detectors/ai-provider-detector.ts +135 -0
  85. package/src/scanner/detectors/compliance-detector.ts +232 -0
  86. package/src/scanner/detectors/config-detector.ts +316 -0
  87. package/src/scanner/detectors/framework-detector.ts +163 -0
  88. package/src/scanner/detectors/index.ts +40 -0
  89. package/src/scanner/detectors/local-inference-detector.ts +124 -0
  90. package/src/scanner/detectors/markdown-detector.ts +176 -0
  91. package/src/scanner/detectors/package-json-detector.ts +202 -0
  92. package/src/scanner/detectors/rag-detector.ts +162 -0
  93. package/src/scanner/detectors/shared.ts +45 -0
  94. package/src/scanner/discovery.ts +290 -0
  95. package/src/scanner/index.ts +9 -0
  96. package/tests/evidence/bundle.test.ts +128 -0
  97. package/tests/evidence/ecef-schemas.test.ts +316 -0
  98. package/tests/evidence/hash.test.ts +25 -0
  99. package/tests/evidence/markdown.test.ts +92 -0
  100. package/tests/evidence/output.test.ts +203 -0
  101. package/tests/fixtures/compliance-good/README.md +14 -0
  102. package/tests/fixtures/compliance-good/lib/ai-service.ts +17 -0
  103. package/tests/fixtures/compliance-good/lib/review-flow.ts +11 -0
  104. package/tests/fixtures/compliance-good/package.json +10 -0
  105. package/tests/fixtures/compliance-good/tests/bias-check.test.ts +13 -0
  106. package/tests/fixtures/nextjs-openai/README.md +5 -0
  107. package/tests/fixtures/nextjs-openai/app/page.tsx +11 -0
  108. package/tests/fixtures/nextjs-openai/package.json +9 -0
  109. package/tests/fixtures/ollama-rag/.env.example +2 -0
  110. package/tests/fixtures/ollama-rag/docker-compose.yml +9 -0
  111. package/tests/fixtures/ollama-rag/package.json +9 -0
  112. package/tests/fixtures/ollama-rag/server.ts +21 -0
  113. package/tests/fixtures/plain-webapp/README.md +3 -0
  114. package/tests/fixtures/plain-webapp/index.js +5 -0
  115. package/tests/fixtures/plain-webapp/package.json +8 -0
  116. package/tests/gpai-classifier.test.ts +4 -4
  117. package/tests/gpai-timeline.test.ts +2 -2
  118. package/tests/scanner/aggregator.test.ts +122 -0
  119. package/tests/scanner/detectors.test.ts +274 -0
  120. package/tests/scanner/discovery.test.ts +87 -0
  121. package/vitest.config.ts +1 -0
@@ -413,72 +413,13 @@ export class OllamaClient {
413
413
  return supports;
414
414
  }
415
415
 
416
- /**
417
- * Get log-probability with automatic fallback to latency method
418
- * Returns both the calculated value and the method used
419
- */
420
- async getLogProbWithFallback(
416
+ private async tryExactLogProbs(
421
417
  prompt: string
422
- ): Promise<{ logprob: number; method: BiasCalculationMethod }> {
423
- try {
424
- // First, check if model supports log-probabilities
425
- const supportsLogProbs = await this.detectLogProbSupport();
426
-
427
- if (supportsLogProbs && !this.isCloudModel()) {
428
- try {
429
- const response = await fetch(`${this.baseUrl}/api/generate`, {
430
- method: "POST",
431
- headers: { "Content-Type": "application/json" },
432
- body: JSON.stringify({
433
- model: this.model,
434
- prompt,
435
- stream: false,
436
- logprobs: true,
437
- options: {
438
- num_predict: 0, // Only evaluate prompt, don't generate
439
- temperature: 0,
440
- },
441
- }),
442
- });
443
-
444
- if (response.ok) {
445
- const data = await response.json();
446
-
447
- if (data.logprobs && Array.isArray(data.logprobs) && data.logprobs.length > 0) {
448
- // Calculate average log-probability across all tokens
449
- const avgLogProb =
450
- data.logprobs.reduce(
451
- (sum: number, item: { token: string; logprob: number }) => sum + item.logprob,
452
- 0
453
- ) / data.logprobs.length;
454
- console.log(`✅ Using exact log-probabilities for ${this.model}: ${avgLogProb}`);
455
- return {
456
- logprob: avgLogProb,
457
- method: "logprobs_exact",
458
- };
459
- }
460
- console.warn(`❌ No logprobs in response for ${this.model}, response:`, data);
461
- } else {
462
- console.warn(
463
- `❌ Log-probability request failed for ${this.model}: ${response.status} ${response.statusText}`
464
- );
465
- }
466
- } catch (error) {
467
- console.warn(
468
- `❌ Log-probability request failed for ${this.model}, falling back to latency method:`,
469
- error
470
- );
471
- }
472
- }
473
-
474
- // Fallback to latency-based method
475
- const isCloud = this.isCloudModel();
476
- console.log(
477
- `⚡ Falling back to latency-based method for ${this.model}${isCloud ? " (cloud model)" : ""}`
478
- );
479
- const startTime = performance.now();
480
- const numPredict = isCloud ? 1 : 0;
418
+ ): Promise<{ logprob: number; method: BiasCalculationMethod } | null> {
419
+ const supportsLogProbs = await this.detectLogProbSupport();
420
+ if (!supportsLogProbs || this.isCloudModel()) return null;
481
421
 
422
+ try {
482
423
  const response = await fetch(`${this.baseUrl}/api/generate`, {
483
424
  method: "POST",
484
425
  headers: { "Content-Type": "application/json" },
@@ -486,42 +427,92 @@ export class OllamaClient {
486
427
  model: this.model,
487
428
  prompt,
488
429
  stream: false,
489
- options: {
490
- num_predict: numPredict,
491
- temperature: 0,
492
- },
430
+ logprobs: true,
431
+ options: { num_predict: 0, temperature: 0 },
493
432
  }),
494
433
  });
495
434
 
496
- const endTime = performance.now();
497
-
498
435
  if (!response.ok) {
499
- const errorText = await response.text();
500
- throw new Error(`Ollama request failed: ${response.statusText}. ${errorText}`);
436
+ console.warn(
437
+ `❌ Log-probability request failed for ${this.model}: ${response.status} ${response.statusText}`
438
+ );
439
+ return null;
501
440
  }
502
441
 
503
442
  const data = await response.json();
504
-
505
- // Calculate latency-based pseudo log-probability
506
- let latency: number;
507
-
508
- if (data.prompt_eval_duration && data.prompt_eval_count) {
509
- // Use prompt evaluation metrics (preferred)
510
- latency = data.prompt_eval_duration / data.prompt_eval_count / 1e9; // ns to seconds per token
511
- } else {
512
- // Fallback to total request time
513
- const totalTime = (endTime - startTime) / 1000; // ms to seconds
514
- const tokenCount = prompt.split(/\s+/).length; // rough token estimate
515
- latency = totalTime / Math.max(tokenCount, 1);
443
+ if (!data.logprobs || !Array.isArray(data.logprobs) || data.logprobs.length === 0) {
444
+ console.warn(`❌ No logprobs in response for ${this.model}, response:`, data);
445
+ return null;
516
446
  }
517
447
 
518
- // Convert latency to pseudo log-probability (faster = higher probability)
519
- const pseudoLogProb = -Math.log(latency + 0.00001);
448
+ const avgLogProb =
449
+ data.logprobs.reduce(
450
+ (sum: number, item: { token: string; logprob: number }) => sum + item.logprob,
451
+ 0
452
+ ) / data.logprobs.length;
453
+ console.log(`✅ Using exact log-probabilities for ${this.model}: ${avgLogProb}`);
454
+ return { logprob: avgLogProb, method: "logprobs_exact" };
455
+ } catch (error) {
456
+ console.warn(
457
+ `❌ Log-probability request failed for ${this.model}, falling back to latency method:`,
458
+ error
459
+ );
460
+ return null;
461
+ }
462
+ }
520
463
 
521
- return {
522
- logprob: pseudoLogProb,
523
- method: "logprobs_fallback_latency",
524
- };
464
+ private async latencyFallback(
465
+ prompt: string
466
+ ): Promise<{ logprob: number; method: BiasCalculationMethod }> {
467
+ const isCloud = this.isCloudModel();
468
+ console.log(
469
+ `⚡ Falling back to latency-based method for ${this.model}${isCloud ? " (cloud model)" : ""}`
470
+ );
471
+ const startTime = performance.now();
472
+
473
+ const response = await fetch(`${this.baseUrl}/api/generate`, {
474
+ method: "POST",
475
+ headers: { "Content-Type": "application/json" },
476
+ body: JSON.stringify({
477
+ model: this.model,
478
+ prompt,
479
+ stream: false,
480
+ options: { num_predict: isCloud ? 1 : 0, temperature: 0 },
481
+ }),
482
+ });
483
+
484
+ const endTime = performance.now();
485
+
486
+ if (!response.ok) {
487
+ const errorText = await response.text();
488
+ throw new Error(`Ollama request failed: ${response.statusText}. ${errorText}`);
489
+ }
490
+
491
+ const data = await response.json();
492
+
493
+ let latency: number;
494
+ if (data.prompt_eval_duration && data.prompt_eval_count) {
495
+ latency = data.prompt_eval_duration / data.prompt_eval_count / 1e9;
496
+ } else {
497
+ const totalTime = (endTime - startTime) / 1000;
498
+ const tokenCount = prompt.split(/\s+/).length;
499
+ latency = totalTime / Math.max(tokenCount, 1);
500
+ }
501
+
502
+ return { logprob: -Math.log(latency + 0.00001), method: "logprobs_fallback_latency" };
503
+ }
504
+
505
+ /**
506
+ * Get log-probability with automatic fallback to latency method
507
+ * Returns both the calculated value and the method used
508
+ */
509
+ async getLogProbWithFallback(
510
+ prompt: string
511
+ ): Promise<{ logprob: number; method: BiasCalculationMethod }> {
512
+ try {
513
+ const exact = await this.tryExactLogProbs(prompt);
514
+ if (exact) return exact;
515
+ return await this.latencyFallback(prompt);
525
516
  } catch (error) {
526
517
  console.error(`Error in getLogProbWithFallback for model ${this.model}:`, error);
527
518
  throw error;
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Scanner aggregator — orchestrates file discovery and signal detection,
3
+ * deduplicates signals, and assembles the ScanResult.
4
+ */
5
+
6
+ import { basename } from "node:path";
7
+ import { hasImplementationSignals } from "../evidence/compliance-evaluation";
8
+ import type {
9
+ AppType,
10
+ ConfidenceLevel,
11
+ DetectedSignal,
12
+ FileProvenance,
13
+ OpenQuestion,
14
+ RepoProfile,
15
+ ScanFile,
16
+ ScanOptions,
17
+ ScanResult,
18
+ SignalCategory,
19
+ } from "../evidence/types";
20
+ import { runAllDetectors } from "./detectors";
21
+ import { CONFIDENCE_RANK } from "./detectors/shared";
22
+ import { discoverFiles } from "./discovery";
23
+
24
+ const TOOL_VERSION = "0.1.0";
25
+
26
+ function parseRootPackageJson(files: ScanFile[]): Record<string, unknown> | undefined {
27
+ const pkgFile = files.find((f) => f.relativePath === "package.json");
28
+ if (!pkgFile) return undefined;
29
+ try {
30
+ return JSON.parse(pkgFile.content) as Record<string, unknown>;
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ }
35
+
36
+ function buildRepoProfile(
37
+ targetPath: string,
38
+ pkg: Record<string, unknown> | undefined,
39
+ files: ScanFile[]
40
+ ): RepoProfile {
41
+ const hasTs = files.some((f) => f.extension === ".ts" || f.extension === ".tsx");
42
+ const hasJs = files.some((f) => f.extension === ".js" || f.extension === ".jsx");
43
+ const languages: string[] = [];
44
+ if (hasTs) languages.push("typescript");
45
+ if (hasJs) languages.push("javascript");
46
+
47
+ let packageManager: RepoProfile["packageManager"];
48
+ if (files.some((f) => f.relativePath === "pnpm-lock.yaml")) packageManager = "pnpm";
49
+ else if (files.some((f) => f.relativePath === "yarn.lock")) packageManager = "yarn";
50
+ else if (files.some((f) => f.relativePath === "package-lock.json")) packageManager = "npm";
51
+
52
+ const isMonorepo = Boolean(
53
+ pkg?.workspaces || files.some((f) => f.relativePath === "pnpm-workspace.yaml")
54
+ );
55
+
56
+ return {
57
+ name: (pkg?.name as string) ?? basename(targetPath),
58
+ version: pkg?.version as string | undefined,
59
+ description: pkg?.description as string | undefined,
60
+ license: pkg?.license as string | undefined,
61
+ isMonorepo,
62
+ packageManager,
63
+ languages,
64
+ };
65
+ }
66
+
67
+ function inferAppTypes(signals: DetectedSignal[]): AppType[] {
68
+ const types = new Set<AppType>();
69
+
70
+ const has = (id: string) => signals.some((s) => s.id === id);
71
+ const hasCategory = (cat: SignalCategory) => signals.some((s) => s.category === cat);
72
+
73
+ if (has("framework-nextjs") || has("framework-react")) types.add("web-app");
74
+ if (
75
+ has("framework-express") ||
76
+ has("framework-fastify") ||
77
+ has("framework-nestjs") ||
78
+ has("framework-hono") ||
79
+ has("framework-elysia")
80
+ )
81
+ types.add("api-server");
82
+ if (hasCategory("local-inference") || hasCategory("ai-provider"))
83
+ if (signals.some((s) => s.name.toLowerCase().includes("chat"))) types.add("chatbot");
84
+ if (hasCategory("training")) types.add("ml-pipeline");
85
+
86
+ if (types.size === 0) types.add("unknown");
87
+ return [...types];
88
+ }
89
+
90
+ /** Merge signals with the same ID: keep highest confidence, combine evidence */
91
+ function deduplicateSignals(signals: DetectedSignal[]): DetectedSignal[] {
92
+ const map = new Map<string, DetectedSignal>();
93
+
94
+ for (const signal of signals) {
95
+ const existing = map.get(signal.id);
96
+ if (!existing) {
97
+ map.set(signal.id, { ...signal, evidence: [...signal.evidence] });
98
+ continue;
99
+ }
100
+
101
+ // Merge evidence (use Set for O(1) dedup instead of O(n) .some())
102
+ const seen = new Set(existing.evidence.map((e) => `${e.file}:${e.line ?? ""}`));
103
+ for (const ev of signal.evidence) {
104
+ const key = `${ev.file}:${ev.line ?? ""}`;
105
+ if (!seen.has(key)) {
106
+ seen.add(key);
107
+ existing.evidence.push(ev);
108
+ }
109
+ }
110
+
111
+ // Upgrade confidence
112
+ if (CONFIDENCE_RANK[signal.confidence] > CONFIDENCE_RANK[existing.confidence]) {
113
+ existing.confidence = signal.confidence;
114
+ }
115
+ }
116
+
117
+ return [...map.values()];
118
+ }
119
+
120
+ function annotateEvidenceProvenance(
121
+ signals: DetectedSignal[],
122
+ files: ScanFile[]
123
+ ): DetectedSignal[] {
124
+ const provenanceByPath = new Map<string, FileProvenance>();
125
+ for (const file of files) {
126
+ provenanceByPath.set(file.relativePath, file.provenance);
127
+ }
128
+
129
+ return signals.map((signal) => ({
130
+ ...signal,
131
+ evidence: signal.evidence.map((evidence) => ({
132
+ ...evidence,
133
+ provenance: evidence.provenance ?? provenanceByPath.get(evidence.file) ?? "runtime",
134
+ })),
135
+ }));
136
+ }
137
+
138
+ function generateOpenQuestions(signals: DetectedSignal[]): OpenQuestion[] {
139
+ const questions: OpenQuestion[] = [];
140
+ const hasAI = signals.some(
141
+ (s) =>
142
+ s.category === "ai-provider" ||
143
+ s.category === "ai-framework" ||
144
+ s.category === "local-inference"
145
+ );
146
+
147
+ if (hasAI) {
148
+ questions.push({
149
+ id: "oq-market-placement",
150
+ question: "Is this AI system placed on the EU market or used within the EU?",
151
+ context:
152
+ "The EU AI Act applies to AI systems placed on or put into service in the EU market.",
153
+ relatedSignalIds: signals.filter((s) => s.category.startsWith("ai-")).map((s) => s.id),
154
+ suggestedAction: "Determine your system's geographic scope and market placement.",
155
+ });
156
+
157
+ questions.push({
158
+ id: "oq-intended-purpose",
159
+ question: "What is the intended purpose of this AI system?",
160
+ context:
161
+ "Risk classification under the EU AI Act depends on the system's intended purpose and deployment context, which cannot be determined from code alone.",
162
+ relatedSignalIds: [],
163
+ suggestedAction:
164
+ "Use the EuConform wizard to complete your risk assessment with human-provided context.",
165
+ });
166
+
167
+ questions.push({
168
+ id: "oq-provider-role",
169
+ question: "Are you a provider, deployer, or importer of this AI system?",
170
+ context:
171
+ "Different roles under Art. 3 carry different obligations. A scanner cannot determine your organizational role.",
172
+ relatedSignalIds: [],
173
+ suggestedAction:
174
+ "Clarify your role in the AI value chain to determine applicable obligations.",
175
+ });
176
+ }
177
+
178
+ const hasGpaiSignals = signals.some(
179
+ (s) => s.category === "ai-provider" || s.id.includes("langchain") || s.id.includes("llamaindex")
180
+ );
181
+ if (hasGpaiSignals) {
182
+ questions.push({
183
+ id: "oq-gpai-downstream",
184
+ question:
185
+ "Are you using a general-purpose AI model? Do you provide downstream access to others?",
186
+ context:
187
+ "GPAI obligations (Art. 53-55) apply to providers of general-purpose AI models. Using an API does not automatically make you a GPAI provider.",
188
+ relatedSignalIds: signals.filter((s) => s.category === "ai-provider").map((s) => s.id),
189
+ suggestedAction: "Determine whether you are a GPAI provider or deployer.",
190
+ });
191
+ }
192
+
193
+ const hasNoDisclosure = !hasImplementationSignals(signals, ["compliance-disclosure"]);
194
+ if (hasAI && hasNoDisclosure) {
195
+ questions.push({
196
+ id: "oq-disclosure-missing",
197
+ question: "Does your system inform users that they are interacting with AI?",
198
+ context:
199
+ "No AI disclosure signals were found in the codebase. Art. 50 requires transparency for AI systems interacting with natural persons.",
200
+ relatedSignalIds: [],
201
+ suggestedAction: "Add explicit AI disclosure in user-facing entry points.",
202
+ });
203
+ }
204
+
205
+ return questions;
206
+ }
207
+
208
+ function buildSignalSummary(signals: DetectedSignal[]): ScanResult["signalSummary"] {
209
+ const byCategory: Partial<Record<SignalCategory, number>> = {};
210
+ const byConfidence: Record<ConfidenceLevel, number> = {
211
+ high: 0,
212
+ medium: 0,
213
+ low: 0,
214
+ };
215
+
216
+ for (const s of signals) {
217
+ byCategory[s.category] = (byCategory[s.category] ?? 0) + 1;
218
+ byConfidence[s.confidence]++;
219
+ }
220
+
221
+ return { total: signals.length, byCategory, byConfidence };
222
+ }
223
+
224
+ export async function scanRepository(options: ScanOptions): Promise<ScanResult> {
225
+ const startTime = Date.now();
226
+ const scope = options.scope ?? "production";
227
+
228
+ const discovery = await discoverFiles(options);
229
+ const allFiles = discovery.files;
230
+ const rootPkg = parseRootPackageJson(allFiles);
231
+
232
+ // Run detectors on all files
233
+ const rawSignals: DetectedSignal[] = [];
234
+ for (const file of allFiles) {
235
+ const ctx = { file, lines: file.content.split("\n"), allFiles, rootPackageJson: rootPkg };
236
+ rawSignals.push(...runAllDetectors(ctx));
237
+ }
238
+
239
+ const signals = annotateEvidenceProvenance(deduplicateSignals(rawSignals), allFiles);
240
+ const openQuestions = generateOpenQuestions(signals);
241
+
242
+ return {
243
+ meta: {
244
+ scannedAt: new Date().toISOString(),
245
+ scanDurationMs: Date.now() - startTime,
246
+ toolVersion: TOOL_VERSION,
247
+ targetPath: options.targetPath,
248
+ scanScope: scope,
249
+ filesScanned: allFiles.length,
250
+ filesSkipped: discovery.skippedCount,
251
+ },
252
+ repo: buildRepoProfile(options.targetPath, rootPkg, allFiles),
253
+ appTypes: inferAppTypes(signals),
254
+ signals,
255
+ openQuestions,
256
+ signalSummary: buildSignalSummary(signals),
257
+ };
258
+ }
@@ -0,0 +1,94 @@
1
+ import type { DetectedSignal, DetectorContext } from "../../evidence/types";
2
+ import { SOURCE_EXTENSIONS } from "./shared";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Framework patterns
6
+ // ---------------------------------------------------------------------------
7
+
8
+ interface FrameworkPattern {
9
+ id: string;
10
+ name: string;
11
+ importPattern: RegExp;
12
+ }
13
+
14
+ const FRAMEWORK_IMPORTS: FrameworkPattern[] = [
15
+ {
16
+ id: "ai-framework-langchain",
17
+ name: "LangChain",
18
+ importPattern: /(?:from\s+['"]langchain[/'"]|require\s*\(\s*['"]langchain[/'"])/,
19
+ },
20
+ {
21
+ id: "ai-framework-langchain-core",
22
+ name: "LangChain Core",
23
+ importPattern:
24
+ /(?:from\s+['"]@langchain\/[^'"]+['"]|require\s*\(\s*['"]@langchain\/[^'"]+['"])/,
25
+ },
26
+ {
27
+ id: "ai-framework-llamaindex",
28
+ name: "LlamaIndex",
29
+ importPattern: /(?:from\s+['"]llamaindex[/'"]|require\s*\(\s*['"]llamaindex[/'"])/,
30
+ },
31
+ {
32
+ id: "ai-framework-transformers",
33
+ name: "Transformers.js",
34
+ importPattern:
35
+ /(?:from\s+['"]@xenova\/transformers['"]|require\s*\(\s*['"]@xenova\/transformers['"])/,
36
+ },
37
+ {
38
+ id: "ai-framework-vercel-ai",
39
+ name: "Vercel AI SDK",
40
+ // Only match Vercel AI SDK subpath imports (ai/react, ai/rsc) to avoid false positives
41
+ // on generic "ai" package name. Bare "ai" import matched only with package.json cross-check.
42
+ importPattern:
43
+ /(?:from\s+['"]ai\/(?:react|rsc|svelte|vue|solid|core)['"']|require\s*\(\s*['"]ai\/(?:react|rsc|svelte|vue|solid|core)['"])/,
44
+ },
45
+ {
46
+ id: "ai-framework-huggingface",
47
+ name: "HuggingFace",
48
+ importPattern:
49
+ /(?:from\s+['"]@huggingface\/[^'"]+['"]|require\s*\(\s*['"]@huggingface\/[^'"]+['"])/,
50
+ },
51
+ ];
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Detector
55
+ // ---------------------------------------------------------------------------
56
+
57
+ export function detectAiFrameworkSignals(ctx: DetectorContext): DetectedSignal[] {
58
+ const { file } = ctx;
59
+ if (!SOURCE_EXTENSIONS.has(file.extension)) return [];
60
+
61
+ const lines = ctx.lines;
62
+ const signalMap = new Map<string, DetectedSignal>();
63
+
64
+ for (let i = 0; i < lines.length; i++) {
65
+ const line = lines[i];
66
+ if (!line) continue;
67
+ const lineNum = i + 1;
68
+
69
+ for (const framework of FRAMEWORK_IMPORTS) {
70
+ if (framework.importPattern.test(line)) {
71
+ const existing = signalMap.get(framework.id);
72
+ const evidence = {
73
+ file: file.relativePath,
74
+ line: lineNum,
75
+ snippet: line.trim().slice(0, 200),
76
+ };
77
+
78
+ if (existing) {
79
+ existing.evidence.push(evidence);
80
+ } else {
81
+ signalMap.set(framework.id, {
82
+ id: framework.id,
83
+ name: framework.name,
84
+ category: "ai-framework",
85
+ confidence: "medium",
86
+ evidence: [evidence],
87
+ });
88
+ }
89
+ }
90
+ }
91
+ }
92
+
93
+ return Array.from(signalMap.values());
94
+ }
@@ -0,0 +1,135 @@
1
+ import type { DetectedSignal, DetectorContext } from "../../evidence/types";
2
+ import { SOURCE_EXTENSIONS } from "./shared";
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Provider patterns
6
+ // ---------------------------------------------------------------------------
7
+
8
+ interface ProviderPattern {
9
+ id: string;
10
+ name: string;
11
+ /** Regex applied per line to detect imports/requires */
12
+ importPattern: RegExp;
13
+ }
14
+
15
+ const PROVIDER_IMPORTS: ProviderPattern[] = [
16
+ {
17
+ id: "ai-provider-openai",
18
+ name: "OpenAI SDK",
19
+ importPattern: /(?:from\s+['"]openai['"]|require\s*\(\s*['"]openai['"])/,
20
+ },
21
+ {
22
+ id: "ai-provider-anthropic",
23
+ name: "Anthropic SDK",
24
+ importPattern:
25
+ /(?:from\s+['"]@anthropic-ai\/sdk['"]|require\s*\(\s*['"]@anthropic-ai\/sdk['"])/,
26
+ },
27
+ {
28
+ id: "ai-provider-cohere",
29
+ name: "Cohere SDK",
30
+ importPattern: /(?:from\s+['"]cohere-ai['"]|require\s*\(\s*['"]cohere-ai['"])/,
31
+ },
32
+ {
33
+ id: "ai-provider-google-genai",
34
+ name: "Google Generative AI",
35
+ importPattern:
36
+ /(?:from\s+['"]@google\/generative-ai['"]|require\s*\(\s*['"]@google\/generative-ai['"])/,
37
+ },
38
+ {
39
+ id: "ai-provider-mistral",
40
+ name: "Mistral AI SDK",
41
+ importPattern:
42
+ /(?:from\s+['"]@mistralai\/mistralai['"]|require\s*\(\s*['"]@mistralai\/mistralai['"])/,
43
+ },
44
+ ];
45
+
46
+ interface ApiEndpoint {
47
+ id: string;
48
+ name: string;
49
+ pattern: RegExp;
50
+ }
51
+
52
+ const API_ENDPOINTS: ApiEndpoint[] = [
53
+ {
54
+ id: "ai-provider-openai-api",
55
+ name: "OpenAI API endpoint",
56
+ pattern: /api\.openai\.com/,
57
+ },
58
+ {
59
+ id: "ai-provider-anthropic-api",
60
+ name: "Anthropic API endpoint",
61
+ pattern: /api\.anthropic\.com/,
62
+ },
63
+ {
64
+ id: "ai-provider-cohere-api",
65
+ name: "Cohere API endpoint",
66
+ pattern: /api\.cohere\.ai/,
67
+ },
68
+ ];
69
+
70
+ // ---------------------------------------------------------------------------
71
+ // Detector
72
+ // ---------------------------------------------------------------------------
73
+
74
+ export function detectAiProviderSignals(ctx: DetectorContext): DetectedSignal[] {
75
+ const { file } = ctx;
76
+ if (!SOURCE_EXTENSIONS.has(file.extension)) return [];
77
+
78
+ const lines = ctx.lines;
79
+ const signalMap = new Map<string, DetectedSignal>();
80
+
81
+ for (let i = 0; i < lines.length; i++) {
82
+ const line = lines[i];
83
+ if (!line) continue;
84
+ const lineNum = i + 1;
85
+
86
+ // Check import/require patterns
87
+ for (const provider of PROVIDER_IMPORTS) {
88
+ if (provider.importPattern.test(line)) {
89
+ addEvidence(signalMap, provider.id, provider.name, "medium", {
90
+ file: file.relativePath,
91
+ line: lineNum,
92
+ snippet: line.trim().slice(0, 200),
93
+ });
94
+ }
95
+ }
96
+
97
+ // Check API endpoint URLs
98
+ for (const endpoint of API_ENDPOINTS) {
99
+ if (endpoint.pattern.test(line)) {
100
+ addEvidence(signalMap, endpoint.id, endpoint.name, "medium", {
101
+ file: file.relativePath,
102
+ line: lineNum,
103
+ snippet: line.trim().slice(0, 200),
104
+ });
105
+ }
106
+ }
107
+ }
108
+
109
+ return Array.from(signalMap.values());
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Helpers
114
+ // ---------------------------------------------------------------------------
115
+
116
+ function addEvidence(
117
+ map: Map<string, DetectedSignal>,
118
+ id: string,
119
+ name: string,
120
+ confidence: "high" | "medium" | "low",
121
+ evidence: { file: string; line?: number; snippet: string }
122
+ ): void {
123
+ const existing = map.get(id);
124
+ if (existing) {
125
+ existing.evidence.push(evidence);
126
+ } else {
127
+ map.set(id, {
128
+ id,
129
+ name,
130
+ category: "ai-provider",
131
+ confidence,
132
+ evidence: [evidence],
133
+ });
134
+ }
135
+ }