@euconform/core 1.0.1 → 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 (183) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +12 -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/index.d.ts +2 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +1 -1
  30. package/dist/inference/ollama-client.d.ts +3 -0
  31. package/dist/inference/ollama-client.d.ts.map +1 -1
  32. package/dist/inference/ollama-client.js +69 -71
  33. package/dist/legal-checks/annex-iv-report.d.ts +3 -0
  34. package/dist/legal-checks/annex-iv-report.d.ts.map +1 -1
  35. package/dist/legal-checks/annex-iv-report.js +1 -0
  36. package/dist/legal-checks/deadline-timeline.d.ts +57 -0
  37. package/dist/legal-checks/deadline-timeline.d.ts.map +1 -0
  38. package/dist/legal-checks/deadline-timeline.js +265 -0
  39. package/dist/legal-checks/gap-analysis.d.ts +48 -0
  40. package/dist/legal-checks/gap-analysis.d.ts.map +1 -0
  41. package/dist/legal-checks/gap-analysis.js +397 -0
  42. package/dist/legal-checks/gpai-classifier.d.ts +49 -0
  43. package/dist/legal-checks/gpai-classifier.d.ts.map +1 -0
  44. package/dist/legal-checks/gpai-classifier.js +197 -0
  45. package/dist/legal-checks/index.d.ts +6 -0
  46. package/dist/legal-checks/index.d.ts.map +1 -1
  47. package/dist/legal-checks/index.js +3 -0
  48. package/dist/scanner/aggregator.d.ts +7 -0
  49. package/dist/scanner/aggregator.d.ts.map +1 -0
  50. package/dist/scanner/aggregator.js +200 -0
  51. package/dist/scanner/detectors/ai-framework-detector.d.ts +3 -0
  52. package/dist/scanner/detectors/ai-framework-detector.d.ts.map +1 -0
  53. package/dist/scanner/detectors/ai-framework-detector.js +74 -0
  54. package/dist/scanner/detectors/ai-provider-detector.d.ts +3 -0
  55. package/dist/scanner/detectors/ai-provider-detector.d.ts.map +1 -0
  56. package/dist/scanner/detectors/ai-provider-detector.js +100 -0
  57. package/dist/scanner/detectors/compliance-detector.d.ts +3 -0
  58. package/dist/scanner/detectors/compliance-detector.d.ts.map +1 -0
  59. package/dist/scanner/detectors/compliance-detector.js +201 -0
  60. package/dist/scanner/detectors/config-detector.d.ts +3 -0
  61. package/dist/scanner/detectors/config-detector.d.ts.map +1 -0
  62. package/dist/scanner/detectors/config-detector.js +248 -0
  63. package/dist/scanner/detectors/framework-detector.d.ts +3 -0
  64. package/dist/scanner/detectors/framework-detector.d.ts.map +1 -0
  65. package/dist/scanner/detectors/framework-detector.js +141 -0
  66. package/dist/scanner/detectors/index.d.ts +8 -0
  67. package/dist/scanner/detectors/index.d.ts.map +1 -0
  68. package/dist/scanner/detectors/index.js +37 -0
  69. package/dist/scanner/detectors/local-inference-detector.d.ts +3 -0
  70. package/dist/scanner/detectors/local-inference-detector.d.ts.map +1 -0
  71. package/dist/scanner/detectors/local-inference-detector.js +93 -0
  72. package/dist/scanner/detectors/markdown-detector.d.ts +3 -0
  73. package/dist/scanner/detectors/markdown-detector.d.ts.map +1 -0
  74. package/dist/scanner/detectors/markdown-detector.js +139 -0
  75. package/dist/scanner/detectors/package-json-detector.d.ts +3 -0
  76. package/dist/scanner/detectors/package-json-detector.d.ts.map +1 -0
  77. package/dist/scanner/detectors/package-json-detector.js +166 -0
  78. package/dist/scanner/detectors/rag-detector.d.ts +3 -0
  79. package/dist/scanner/detectors/rag-detector.d.ts.map +1 -0
  80. package/dist/scanner/detectors/rag-detector.js +132 -0
  81. package/dist/scanner/detectors/shared.d.ts +18 -0
  82. package/dist/scanner/detectors/shared.d.ts.map +1 -0
  83. package/dist/scanner/detectors/shared.js +36 -0
  84. package/dist/scanner/discovery.d.ts +14 -0
  85. package/dist/scanner/discovery.d.ts.map +1 -0
  86. package/dist/scanner/discovery.js +235 -0
  87. package/dist/scanner/index.d.ts +9 -0
  88. package/dist/scanner/index.d.ts.map +1 -0
  89. package/dist/scanner/index.js +8 -0
  90. package/package.json +6 -3
  91. package/src/evidence/bundle.ts +65 -0
  92. package/src/evidence/compliance-evaluation.ts +114 -0
  93. package/src/evidence/hash.ts +5 -0
  94. package/src/evidence/index.ts +51 -0
  95. package/src/evidence/markdown.ts +226 -0
  96. package/src/evidence/output.ts +717 -0
  97. package/src/evidence/types.ts +329 -0
  98. package/src/evidence/validate.ts +270 -0
  99. package/src/index.ts +25 -0
  100. package/src/inference/ollama-client.ts +93 -86
  101. package/src/legal-checks/annex-iv-report.ts +4 -0
  102. package/src/legal-checks/deadline-timeline.ts +313 -0
  103. package/src/legal-checks/gap-analysis.ts +454 -0
  104. package/src/legal-checks/gpai-classifier.ts +281 -0
  105. package/src/legal-checks/index.ts +33 -0
  106. package/src/scanner/aggregator.ts +258 -0
  107. package/src/scanner/detectors/ai-framework-detector.ts +94 -0
  108. package/src/scanner/detectors/ai-provider-detector.ts +135 -0
  109. package/src/scanner/detectors/compliance-detector.ts +232 -0
  110. package/src/scanner/detectors/config-detector.ts +316 -0
  111. package/src/scanner/detectors/framework-detector.ts +163 -0
  112. package/src/scanner/detectors/index.ts +40 -0
  113. package/src/scanner/detectors/local-inference-detector.ts +124 -0
  114. package/src/scanner/detectors/markdown-detector.ts +176 -0
  115. package/src/scanner/detectors/package-json-detector.ts +202 -0
  116. package/src/scanner/detectors/rag-detector.ts +162 -0
  117. package/src/scanner/detectors/shared.ts +45 -0
  118. package/src/scanner/discovery.ts +290 -0
  119. package/src/scanner/index.ts +9 -0
  120. package/tests/evidence/bundle.test.ts +128 -0
  121. package/tests/evidence/ecef-schemas.test.ts +316 -0
  122. package/tests/evidence/hash.test.ts +25 -0
  123. package/tests/evidence/markdown.test.ts +92 -0
  124. package/tests/evidence/output.test.ts +203 -0
  125. package/tests/fixtures/compliance-good/README.md +14 -0
  126. package/tests/fixtures/compliance-good/lib/ai-service.ts +17 -0
  127. package/tests/fixtures/compliance-good/lib/review-flow.ts +11 -0
  128. package/tests/fixtures/compliance-good/package.json +10 -0
  129. package/tests/fixtures/compliance-good/tests/bias-check.test.ts +13 -0
  130. package/tests/fixtures/nextjs-openai/README.md +5 -0
  131. package/tests/fixtures/nextjs-openai/app/page.tsx +11 -0
  132. package/tests/fixtures/nextjs-openai/package.json +9 -0
  133. package/tests/fixtures/ollama-rag/.env.example +2 -0
  134. package/tests/fixtures/ollama-rag/docker-compose.yml +9 -0
  135. package/tests/fixtures/ollama-rag/package.json +9 -0
  136. package/tests/fixtures/ollama-rag/server.ts +21 -0
  137. package/tests/fixtures/plain-webapp/README.md +3 -0
  138. package/tests/fixtures/plain-webapp/index.js +5 -0
  139. package/tests/fixtures/plain-webapp/package.json +8 -0
  140. package/tests/gpai-classifier.test.ts +125 -0
  141. package/tests/gpai-timeline.test.ts +55 -0
  142. package/tests/scanner/aggregator.test.ts +122 -0
  143. package/tests/scanner/detectors.test.ts +274 -0
  144. package/tests/scanner/discovery.test.ts +87 -0
  145. package/vitest.config.ts +1 -0
  146. package/.turbo/turbo-check-types.log +0 -4
  147. package/.turbo/turbo-test.log +0 -364
  148. package/coverage/base.css +0 -224
  149. package/coverage/block-navigation.js +0 -87
  150. package/coverage/clover.xml +0 -3282
  151. package/coverage/coverage-final.json +0 -20
  152. package/coverage/favicon.png +0 -0
  153. package/coverage/index.html +0 -161
  154. package/coverage/prettify.css +0 -1
  155. package/coverage/prettify.js +0 -2
  156. package/coverage/sort-arrow-sprite.png +0 -0
  157. package/coverage/sorter.js +0 -210
  158. package/coverage/src/fairness-metrics.ts.html +0 -1579
  159. package/coverage/src/index.html +0 -161
  160. package/coverage/src/index.ts.html +0 -535
  161. package/coverage/src/inference/browser-inference.ts.html +0 -859
  162. package/coverage/src/inference/capability-detection.ts.html +0 -2227
  163. package/coverage/src/inference/index.html +0 -176
  164. package/coverage/src/inference/index.ts.html +0 -553
  165. package/coverage/src/inference/inference-factory.ts.html +0 -751
  166. package/coverage/src/inference/ollama-client.ts.html +0 -1627
  167. package/coverage/src/legal-checks/annex-iv-report.ts.html +0 -556
  168. package/coverage/src/legal-checks/bias-metrics.ts.html +0 -1855
  169. package/coverage/src/legal-checks/data-governance.ts.html +0 -529
  170. package/coverage/src/legal-checks/human-oversight-logging.ts.html +0 -490
  171. package/coverage/src/legal-checks/index.html +0 -206
  172. package/coverage/src/legal-checks/index.ts.html +0 -250
  173. package/coverage/src/legal-checks/risk-classifier.ts.html +0 -940
  174. package/coverage/src/legal-checks/robustness-transparency.ts.html +0 -571
  175. package/coverage/src/metrics/crows-pairs-bias.ts.html +0 -820
  176. package/coverage/src/metrics/index.html +0 -146
  177. package/coverage/src/metrics/index.ts.html +0 -151
  178. package/coverage/src/metrics/stereotype-bias.ts.html +0 -886
  179. package/coverage/src/risk-engine.ts.html +0 -1660
  180. package/coverage/src/types.ts.html +0 -547
  181. package/dist/legal-checks/disclaimer.d.ts +0 -2
  182. package/dist/legal-checks/disclaimer.d.ts.map +0 -1
  183. package/dist/legal-checks/disclaimer.js +0 -1
@@ -0,0 +1,316 @@
1
+ import { readFileSync, readdirSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ type JsonSchema = {
6
+ $defs?: Record<string, JsonSchema>;
7
+ $ref?: string;
8
+ const?: unknown;
9
+ enum?: unknown[];
10
+ type?: "object" | "array" | "string" | "boolean" | "integer";
11
+ required?: string[];
12
+ properties?: Record<string, JsonSchema>;
13
+ additionalProperties?: boolean;
14
+ items?: JsonSchema;
15
+ format?: string;
16
+ pattern?: string;
17
+ minItems?: number;
18
+ };
19
+
20
+ const ROOT = resolve(__dirname, "../../../..");
21
+ const SCHEMAS_DIR = resolve(ROOT, "docs/ecef/schemas");
22
+ const EXAMPLES_DIR = resolve(ROOT, "docs/ecef/examples");
23
+
24
+ function readJson<T>(path: string): T {
25
+ return JSON.parse(readFileSync(path, "utf8")) as T;
26
+ }
27
+
28
+ function resolveRef(rootSchema: JsonSchema, ref: string): JsonSchema {
29
+ if (!ref.startsWith("#/")) {
30
+ throw new Error(`Unsupported schema ref: ${ref}`);
31
+ }
32
+
33
+ const pathSegments = ref.slice(2).split("/");
34
+ let current: unknown = rootSchema;
35
+
36
+ for (const segment of pathSegments) {
37
+ if (!current || typeof current !== "object" || !(segment in current)) {
38
+ throw new Error(`Unable to resolve schema ref: ${ref}`);
39
+ }
40
+ current = (current as Record<string, unknown>)[segment];
41
+ }
42
+
43
+ return current as JsonSchema;
44
+ }
45
+
46
+ function checkAdditionalProperties(
47
+ schema: JsonSchema,
48
+ objectValue: Record<string, unknown>,
49
+ path: string
50
+ ): string[] {
51
+ if (schema.additionalProperties !== false || !schema.properties) return [];
52
+ const allowed = new Set(Object.keys(schema.properties));
53
+ return Object.keys(objectValue)
54
+ .filter((key) => !allowed.has(key))
55
+ .map((key) => `${path}.${key} is not allowed (additionalProperties: false)`);
56
+ }
57
+
58
+ function validateObjectSchema(
59
+ rootSchema: JsonSchema,
60
+ schema: JsonSchema,
61
+ value: unknown,
62
+ path: string
63
+ ): string[] {
64
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
65
+ return [`${path} must be an object`];
66
+ }
67
+
68
+ const errors: string[] = [];
69
+ const objectValue = value as Record<string, unknown>;
70
+
71
+ for (const field of schema.required ?? []) {
72
+ if (!(field in objectValue)) {
73
+ errors.push(`${path}.${field} is required`);
74
+ }
75
+ }
76
+
77
+ errors.push(...checkAdditionalProperties(schema, objectValue, path));
78
+
79
+ for (const [key, propertySchema] of Object.entries(schema.properties ?? {})) {
80
+ if (key in objectValue) {
81
+ errors.push(
82
+ ...validateAgainstSchema(rootSchema, propertySchema, objectValue[key], `${path}.${key}`)
83
+ );
84
+ }
85
+ }
86
+
87
+ return errors;
88
+ }
89
+
90
+ function validateArraySchema(
91
+ rootSchema: JsonSchema,
92
+ schema: JsonSchema,
93
+ value: unknown,
94
+ path: string
95
+ ): string[] {
96
+ if (!Array.isArray(value)) {
97
+ return [`${path} must be an array`];
98
+ }
99
+
100
+ const errors: string[] = [];
101
+
102
+ if (schema.minItems !== undefined && value.length < schema.minItems) {
103
+ errors.push(`${path} must have at least ${schema.minItems} item(s)`);
104
+ }
105
+
106
+ if (!schema.items) return errors;
107
+
108
+ for (let i = 0; i < value.length; i++) {
109
+ errors.push(...validateAgainstSchema(rootSchema, schema.items, value[i], `${path}[${i}]`));
110
+ }
111
+ return errors;
112
+ }
113
+
114
+ function validatePrimitive(schema: JsonSchema, value: unknown, path: string): string[] {
115
+ if (schema.type === "string") {
116
+ if (typeof value !== "string") return [`${path} must be a string`];
117
+ if (schema.pattern && !new RegExp(schema.pattern).test(value)) {
118
+ return [`${path} must match pattern ${schema.pattern}`];
119
+ }
120
+ return [];
121
+ }
122
+ if (schema.type === "boolean" && typeof value !== "boolean") {
123
+ return [`${path} must be a boolean`];
124
+ }
125
+ if (schema.type === "integer" && !Number.isInteger(value)) {
126
+ return [`${path} must be an integer`];
127
+ }
128
+ return [];
129
+ }
130
+
131
+ function validateAgainstSchema(
132
+ rootSchema: JsonSchema,
133
+ schema: JsonSchema,
134
+ value: unknown,
135
+ path = "$"
136
+ ): string[] {
137
+ if (schema.$ref) {
138
+ return validateAgainstSchema(rootSchema, resolveRef(rootSchema, schema.$ref), value, path);
139
+ }
140
+
141
+ if ("const" in schema && value !== schema.const) {
142
+ return [`${path} must equal ${JSON.stringify(schema.const)}`];
143
+ }
144
+
145
+ if (schema.enum && !schema.enum.includes(value)) {
146
+ return [
147
+ `${path} must be one of ${schema.enum.map((entry) => JSON.stringify(entry)).join(", ")}`,
148
+ ];
149
+ }
150
+
151
+ if (schema.type === "object") return validateObjectSchema(rootSchema, schema, value, path);
152
+ if (schema.type === "array") return validateArraySchema(rootSchema, schema, value, path);
153
+
154
+ return validatePrimitive(schema, value, path);
155
+ }
156
+
157
+ const SCHEMA_FILES = {
158
+ report: resolve(SCHEMAS_DIR, "report-v1.schema.json"),
159
+ aibom: resolve(SCHEMAS_DIR, "aibom-v1.schema.json"),
160
+ ci: resolve(SCHEMAS_DIR, "ci-v1.schema.json"),
161
+ bundle: resolve(SCHEMAS_DIR, "bundle-v1.schema.json"),
162
+ } as const;
163
+
164
+ const EXAMPLE_FILES = {
165
+ report: "euconform.report.json",
166
+ aibom: "euconform.aibom.json",
167
+ ci: "euconform.ci.json",
168
+ bundle: "euconform.bundle.json",
169
+ } as const;
170
+
171
+ describe("ECEF schemas", () => {
172
+ const schemaEntries = Object.entries(SCHEMA_FILES).map(([kind, path]) => [
173
+ kind,
174
+ readJson<JsonSchema>(path),
175
+ ]) as Array<[keyof typeof SCHEMA_FILES, JsonSchema]>;
176
+
177
+ it("validates every documented example artifact set", () => {
178
+ const exampleDirs = readdirSync(EXAMPLES_DIR, { withFileTypes: true })
179
+ .filter((entry) => entry.isDirectory())
180
+ .map((entry) => entry.name)
181
+ .sort();
182
+
183
+ expect(exampleDirs).toEqual(["local-ollama", "non-ai", "rag", "web-app"]);
184
+
185
+ for (const exampleDir of exampleDirs) {
186
+ for (const [kind, schema] of schemaEntries) {
187
+ const artifactPath = resolve(EXAMPLES_DIR, exampleDir, EXAMPLE_FILES[kind]);
188
+ const artifact = readJson<unknown>(artifactPath);
189
+ const errors = validateAgainstSchema(schema, schema, artifact);
190
+
191
+ expect(errors, `${exampleDir}/${EXAMPLE_FILES[kind]}\n${errors.join("\n")}`).toEqual([]);
192
+ }
193
+ }
194
+ });
195
+
196
+ it("rejects documents with missing required fields", () => {
197
+ for (const [kind, schema] of schemaEntries) {
198
+ const minimal: Record<string, unknown> = {};
199
+ const errors = validateAgainstSchema(schema, schema, minimal);
200
+ expect(errors.length, `${kind}: empty object should fail validation`).toBeGreaterThan(0);
201
+ }
202
+ });
203
+
204
+ it("rejects documents with wrong types", () => {
205
+ const reportSchema = schemaEntries.find(([kind]) => kind === "report")?.[1];
206
+ if (!reportSchema) throw new Error("report schema not found");
207
+
208
+ const badReport = {
209
+ schemaVersion: "euconform.report.v1",
210
+ generatedAt: 12345,
211
+ tool: "not-an-object",
212
+ target: null,
213
+ aiFootprint: [],
214
+ complianceSignals: "wrong",
215
+ assessmentHints: false,
216
+ gaps: "not-array",
217
+ recommendationSummary: {},
218
+ };
219
+ const errors = validateAgainstSchema(reportSchema, reportSchema, badReport);
220
+ expect(errors.length).toBeGreaterThan(0);
221
+ });
222
+
223
+ it("rejects documents with additional properties", () => {
224
+ const artifactPath = resolve(EXAMPLES_DIR, "web-app", EXAMPLE_FILES.report);
225
+ const artifact = readJson<Record<string, unknown>>(artifactPath);
226
+ const extraProps = { ...artifact, unknownField: "should-fail" };
227
+
228
+ const reportSchema = schemaEntries.find(([kind]) => kind === "report")?.[1];
229
+ if (!reportSchema) throw new Error("report schema not found");
230
+
231
+ const errors = validateAgainstSchema(reportSchema, reportSchema, extraProps);
232
+ expect(errors.some((e) => e.includes("unknownField"))).toBe(true);
233
+ });
234
+
235
+ it("rejects invalid enum values", () => {
236
+ const ciSchema = schemaEntries.find(([kind]) => kind === "ci")?.[1];
237
+ if (!ciSchema) throw new Error("ci schema not found");
238
+
239
+ const artifactPath = resolve(EXAMPLES_DIR, "web-app", EXAMPLE_FILES.ci);
240
+ const artifact = readJson<Record<string, unknown>>(artifactPath);
241
+ const badCi = {
242
+ ...artifact,
243
+ scanScope: "invalid-scope",
244
+ };
245
+ const errors = validateAgainstSchema(ciSchema, ciSchema, badCi);
246
+ expect(errors.some((e) => e.includes("scanScope"))).toBe(true);
247
+ });
248
+
249
+ it("keeps schemaVersion aligned with the documented file family", () => {
250
+ const expectedVersions = {
251
+ report: "euconform.report.v1",
252
+ aibom: "euconform.aibom.v1",
253
+ ci: "euconform.ci.v1",
254
+ bundle: "euconform.bundle.v1",
255
+ } as const;
256
+
257
+ const exampleDirs = readdirSync(EXAMPLES_DIR, { withFileTypes: true })
258
+ .filter((entry) => entry.isDirectory())
259
+ .map((entry) => entry.name);
260
+
261
+ for (const exampleDir of exampleDirs) {
262
+ for (const [kind, filename] of Object.entries(EXAMPLE_FILES) as Array<
263
+ [keyof typeof EXAMPLE_FILES, string]
264
+ >) {
265
+ const artifact = readJson<{ schemaVersion?: string }>(
266
+ resolve(EXAMPLES_DIR, exampleDir, filename)
267
+ );
268
+ expect(artifact.schemaVersion).toBe(expectedVersions[kind]);
269
+ }
270
+ }
271
+ });
272
+
273
+ it("rejects bundle with invalid sha256 format", () => {
274
+ const bundleSchema = schemaEntries.find(([kind]) => kind === "bundle")?.[1];
275
+ if (!bundleSchema) throw new Error("bundle schema not found");
276
+
277
+ const badBundle = {
278
+ schemaVersion: "euconform.bundle.v1",
279
+ generatedAt: "2026-01-01T00:00:00Z",
280
+ tool: { name: "euconform", version: "1.0.0" },
281
+ target: { name: "test", rootPath: "/test" },
282
+ artifacts: [
283
+ {
284
+ role: "report",
285
+ fileName: "euconform.report.json",
286
+ sha256: "not-a-valid-hash",
287
+ required: true,
288
+ },
289
+ ],
290
+ };
291
+ const errors = validateAgainstSchema(bundleSchema, bundleSchema, badBundle);
292
+ expect(errors.some((e) => e.includes("sha256"))).toBe(true);
293
+ });
294
+
295
+ it("rejects bundle with invalid artifact role", () => {
296
+ const bundleSchema = schemaEntries.find(([kind]) => kind === "bundle")?.[1];
297
+ if (!bundleSchema) throw new Error("bundle schema not found");
298
+
299
+ const badBundle = {
300
+ schemaVersion: "euconform.bundle.v1",
301
+ generatedAt: "2026-01-01T00:00:00Z",
302
+ tool: { name: "euconform", version: "1.0.0" },
303
+ target: { name: "test", rootPath: "/test" },
304
+ artifacts: [
305
+ {
306
+ role: "unknown-role",
307
+ fileName: "test.json",
308
+ sha256: "a".repeat(64),
309
+ required: true,
310
+ },
311
+ ],
312
+ };
313
+ const errors = validateAgainstSchema(bundleSchema, bundleSchema, badBundle);
314
+ expect(errors.some((e) => e.includes("role"))).toBe(true);
315
+ });
316
+ });
@@ -0,0 +1,25 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { sha256Hex } from "../../src/evidence/hash";
3
+
4
+ describe("sha256Hex", () => {
5
+ it("produces correct hex digest for known input", () => {
6
+ expect(sha256Hex("hello")).toBe(
7
+ "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
8
+ );
9
+ });
10
+
11
+ it("produces 64-character hex string", () => {
12
+ const result = sha256Hex("test content");
13
+ expect(result).toMatch(/^[a-f0-9]{64}$/);
14
+ });
15
+
16
+ it("handles empty string", () => {
17
+ const result = sha256Hex("");
18
+ expect(result).toMatch(/^[a-f0-9]{64}$/);
19
+ expect(result).toBe("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
20
+ });
21
+
22
+ it("produces different hashes for different inputs", () => {
23
+ expect(sha256Hex("a")).not.toBe(sha256Hex("b"));
24
+ });
25
+ });
@@ -0,0 +1,92 @@
1
+ import { resolve } from "node:path";
2
+ import { describe, expect, it } from "vitest";
3
+ import { generateScanOutput } from "../../src/evidence/output";
4
+ import { scanRepository } from "../../src/scanner/aggregator";
5
+
6
+ const FIXTURES = resolve(import.meta.dirname, "../fixtures");
7
+
8
+ describe("generateSummaryMarkdown", () => {
9
+ it("includes disclaimer text", async () => {
10
+ const scanResult = await scanRepository({
11
+ targetPath: resolve(FIXTURES, "nextjs-openai"),
12
+ });
13
+ const output = generateScanOutput(scanResult);
14
+
15
+ expect(output.summaryMarkdown).toContain("technical guidance only");
16
+ expect(output.summaryMarkdown).toContain("does not constitute legal advice");
17
+ expect(output.summaryMarkdown).toContain("Scan scope: `production`");
18
+ });
19
+
20
+ it("includes project overview section", async () => {
21
+ const scanResult = await scanRepository({
22
+ targetPath: resolve(FIXTURES, "nextjs-openai"),
23
+ });
24
+ const output = generateScanOutput(scanResult);
25
+
26
+ expect(output.summaryMarkdown).toContain("## Project Overview");
27
+ expect(output.summaryMarkdown).toContain(output.report.target.name);
28
+ });
29
+
30
+ it("includes AI components section", async () => {
31
+ const scanResult = await scanRepository({
32
+ targetPath: resolve(FIXTURES, "nextjs-openai"),
33
+ });
34
+ const output = generateScanOutput(scanResult);
35
+
36
+ expect(output.summaryMarkdown).toContain("## AI Components Detected");
37
+ // Table headers
38
+ expect(output.summaryMarkdown).toContain("| Component |");
39
+ expect(output.summaryMarkdown).toContain("| Kind |");
40
+ });
41
+
42
+ it("includes compliance signals table", async () => {
43
+ const scanResult = await scanRepository({
44
+ targetPath: resolve(FIXTURES, "nextjs-openai"),
45
+ });
46
+ const output = generateScanOutput(scanResult);
47
+
48
+ expect(output.summaryMarkdown).toContain("## Compliance Signals");
49
+ expect(output.summaryMarkdown).toContain("| Area | Status |");
50
+ });
51
+
52
+ it("includes open questions section", async () => {
53
+ const scanResult = await scanRepository({
54
+ targetPath: resolve(FIXTURES, "nextjs-openai"),
55
+ });
56
+ const output = generateScanOutput(scanResult);
57
+
58
+ expect(output.summaryMarkdown).toContain("## Open Questions");
59
+ });
60
+
61
+ it("includes recommended actions section", async () => {
62
+ const scanResult = await scanRepository({
63
+ targetPath: resolve(FIXTURES, "nextjs-openai"),
64
+ });
65
+ const output = generateScanOutput(scanResult);
66
+
67
+ expect(output.summaryMarkdown).toContain("## Recommended Actions");
68
+ });
69
+
70
+ it("includes footer with artifact references", async () => {
71
+ const scanResult = await scanRepository({
72
+ targetPath: resolve(FIXTURES, "nextjs-openai"),
73
+ });
74
+ const output = generateScanOutput(scanResult);
75
+
76
+ expect(output.summaryMarkdown).toContain("euconform.report.json");
77
+ expect(output.summaryMarkdown).toContain("euconform.aibom.json");
78
+ });
79
+
80
+ it("handles project with no AI components", async () => {
81
+ const scanResult = await scanRepository({
82
+ targetPath: resolve(FIXTURES, "plain-webapp"),
83
+ });
84
+ const output = generateScanOutput(scanResult);
85
+
86
+ // plain-webapp still lists framework components (e.g. express) in the BOM,
87
+ // but the report should indicate no AI is being used
88
+ expect(output.report.aiFootprint.usesAI).toBe(false);
89
+ // Assessment hints section should reflect no AI detected
90
+ expect(output.summaryMarkdown).toContain("No assessment hints generated (no AI detected)");
91
+ });
92
+ });
@@ -0,0 +1,203 @@
1
+ import { resolve } from "node:path";
2
+ import { describe, expect, it } from "vitest";
3
+ import { generateScanOutput } from "../../src/evidence/output";
4
+ import { scanRepository } from "../../src/scanner/aggregator";
5
+
6
+ const FIXTURES = resolve(__dirname, "../fixtures");
7
+ const MONOREPO_ROOT = resolve(__dirname, "../../../..");
8
+
9
+ describe("generateScanOutput", () => {
10
+ it("generates valid report schema for AI project", async () => {
11
+ const scanResult = await scanRepository({
12
+ targetPath: resolve(FIXTURES, "nextjs-openai"),
13
+ });
14
+ const output = generateScanOutput(scanResult);
15
+
16
+ expect(output.report.schemaVersion).toBe("euconform.report.v1");
17
+ expect(output.report.generatedAt).toBeTruthy();
18
+ expect(output.report.tool.name).toBe("euconform");
19
+ expect(output.report.tool.version).toBeTruthy();
20
+ expect(output.report.target.rootPath).toBeTruthy();
21
+ expect(output.report.target.name).toBeTruthy();
22
+ expect(output.report.aiFootprint).toBeDefined();
23
+ expect(output.report.complianceSignals).toBeDefined();
24
+ expect(output.report.assessmentHints).toBeDefined();
25
+ expect(output.report.gaps).toBeDefined();
26
+ expect(output.report.recommendationSummary).toBeDefined();
27
+ });
28
+
29
+ it("keeps documentation-only disclosure as a hint, not an implementation", async () => {
30
+ const scanResult = await scanRepository({
31
+ targetPath: resolve(FIXTURES, "nextjs-openai"),
32
+ });
33
+ const output = generateScanOutput(scanResult);
34
+
35
+ expect(output.report.complianceSignals.disclosure.status).toBe("absent");
36
+ expect(
37
+ output.report.assessmentHints.riskIndicators.some((hint) =>
38
+ hint.hint.toLowerCase().includes("documentation references ai disclosure")
39
+ )
40
+ ).toBe(true);
41
+ });
42
+
43
+ it("generates valid AIBOM schema", async () => {
44
+ const scanResult = await scanRepository({
45
+ targetPath: resolve(FIXTURES, "nextjs-openai"),
46
+ });
47
+ const output = generateScanOutput(scanResult);
48
+
49
+ expect(output.aibom.schemaVersion).toBe("euconform.aibom.v1");
50
+ expect(output.aibom.project.name).toBeTruthy();
51
+ expect(Array.isArray(output.aibom.components)).toBe(true);
52
+ expect(output.aibom.components.length).toBeGreaterThan(0);
53
+
54
+ // Each component should have required fields
55
+ for (const comp of output.aibom.components) {
56
+ expect(comp.id).toBeTruthy();
57
+ expect(comp.kind).toBeTruthy();
58
+ expect(comp.name).toBeTruthy();
59
+ expect(comp.source).toBeTruthy();
60
+ }
61
+ });
62
+
63
+ it("generates stable, readable AIBOM component ids", { timeout: 15_000 }, async () => {
64
+ const scanResult = await scanRepository({
65
+ targetPath: MONOREPO_ROOT,
66
+ });
67
+ const output = generateScanOutput(scanResult);
68
+ const ids = output.aibom.components.map((component) => component.id);
69
+
70
+ expect(new Set(ids).size).toBe(ids.length);
71
+ expect(ids.every((id) => !id.includes("--"))).toBe(true);
72
+ expect(ids.every((id) => id.includes(":"))).toBe(true);
73
+ });
74
+
75
+ it("derives export capabilities from reporting signals", { timeout: 15_000 }, async () => {
76
+ const scanResult = await scanRepository({
77
+ targetPath: MONOREPO_ROOT,
78
+ });
79
+ const output = generateScanOutput(scanResult);
80
+
81
+ const reportingEvidence = output.report.complianceSignals.reportingExports.evidence;
82
+ expect(output.aibom.complianceCapabilities.jsonExport).toBe(
83
+ reportingEvidence.some(
84
+ (e) => e.file.includes(".json") || e.snippet.toLowerCase().includes("json")
85
+ )
86
+ );
87
+ });
88
+
89
+ it("generates summary markdown", async () => {
90
+ const scanResult = await scanRepository({
91
+ targetPath: resolve(FIXTURES, "nextjs-openai"),
92
+ });
93
+ const output = generateScanOutput(scanResult);
94
+
95
+ expect(typeof output.summaryMarkdown).toBe("string");
96
+ expect(output.summaryMarkdown.length).toBeGreaterThan(0);
97
+
98
+ // Should contain expected markdown sections
99
+ expect(output.summaryMarkdown).toContain("# EuConform Scan Report");
100
+ expect(output.summaryMarkdown).toContain("## Project Overview");
101
+ expect(output.summaryMarkdown).toContain("## AI Components Detected");
102
+ expect(output.summaryMarkdown).toContain("## Compliance Signals");
103
+ });
104
+
105
+ it("reports AI detected = true for AI project", async () => {
106
+ const scanResult = await scanRepository({
107
+ targetPath: resolve(FIXTURES, "nextjs-openai"),
108
+ });
109
+ const output = generateScanOutput(scanResult);
110
+
111
+ expect(output.report.aiFootprint.usesAI).toBe(true);
112
+ expect(output.report.aiFootprint.providerHints.length).toBeGreaterThan(0);
113
+ });
114
+
115
+ it("reports AI detected = false for plain webapp", async () => {
116
+ const scanResult = await scanRepository({
117
+ targetPath: resolve(FIXTURES, "plain-webapp"),
118
+ });
119
+ const output = generateScanOutput(scanResult);
120
+
121
+ expect(output.report.aiFootprint.usesAI).toBe(false);
122
+ expect(output.report.aiFootprint.providerHints).toHaveLength(0);
123
+ expect(output.report.aiFootprint.inferenceModes).toHaveLength(0);
124
+ });
125
+
126
+ it("generates gaps for AI project without compliance signals", async () => {
127
+ const scanResult = await scanRepository({
128
+ targetPath: resolve(FIXTURES, "nextjs-openai"),
129
+ });
130
+ const output = generateScanOutput(scanResult);
131
+
132
+ // nextjs-openai has AI but minimal compliance infrastructure,
133
+ // so gaps should be generated
134
+ expect(output.report.gaps.length).toBeGreaterThan(0);
135
+
136
+ // Each gap should have required fields
137
+ for (const gap of output.report.gaps) {
138
+ expect(gap.id).toBeTruthy();
139
+ expect(gap.title).toBeTruthy();
140
+ expect(gap.description).toBeTruthy();
141
+ expect(["critical", "high", "medium", "low"]).toContain(gap.priority);
142
+ expect(["missing", "partial"]).toContain(gap.status);
143
+ expect(gap.basis).toBe("scanner-rule");
144
+ }
145
+ });
146
+
147
+ it("generates no gaps for plain webapp", async () => {
148
+ const scanResult = await scanRepository({
149
+ targetPath: resolve(FIXTURES, "plain-webapp"),
150
+ });
151
+ const output = generateScanOutput(scanResult);
152
+
153
+ // No AI detected means no compliance gaps
154
+ expect(output.report.gaps).toHaveLength(0);
155
+ });
156
+
157
+ it("includes compliance signals in report", async () => {
158
+ const scanResult = await scanRepository({
159
+ targetPath: resolve(FIXTURES, "compliance-good"),
160
+ });
161
+ const output = generateScanOutput(scanResult);
162
+
163
+ const { complianceSignals } = output.report;
164
+
165
+ // compliance-good has logging (pino) and incident reporting (sentry)
166
+ // At least some compliance areas should not be "absent"
167
+ const statuses = Object.values(complianceSignals).map((g) => g.status);
168
+ const nonAbsent = statuses.filter((s) => s === "present" || s === "partial");
169
+ expect(nonAbsent.length).toBeGreaterThan(0);
170
+ });
171
+
172
+ it("includes recommendations", async () => {
173
+ const scanResult = await scanRepository({
174
+ targetPath: resolve(FIXTURES, "nextjs-openai"),
175
+ });
176
+ const output = generateScanOutput(scanResult);
177
+
178
+ expect(output.report.recommendationSummary.length).toBeGreaterThan(0);
179
+
180
+ // Should contain wizard recommendation or be capped at 5 entries
181
+ const hasWizard = output.report.recommendationSummary.some((r) =>
182
+ r.toLowerCase().includes("wizard")
183
+ );
184
+ // The list is capped at 5 — wizard may be trimmed for projects with many gaps
185
+ if (output.report.recommendationSummary.length < 5) {
186
+ expect(hasWizard).toBe(true);
187
+ }
188
+ expect(output.report.recommendationSummary.length).toBeLessThanOrEqual(5);
189
+ });
190
+
191
+ it("uses softened GPAI wording for cloud providers", async () => {
192
+ const scanResult = await scanRepository({
193
+ targetPath: resolve(FIXTURES, "nextjs-openai"),
194
+ });
195
+ const output = generateScanOutput(scanResult);
196
+
197
+ expect(
198
+ output.report.assessmentHints.gpaiIndicators.some((hint) =>
199
+ hint.hint.includes("Provider obligations depend on your role")
200
+ )
201
+ ).toBe(true);
202
+ });
203
+ });
@@ -0,0 +1,14 @@
1
+ # Compliant AI Service
2
+
3
+ This service integrates AI capabilities with full compliance infrastructure.
4
+
5
+ ## AI Disclosure
6
+
7
+ This service uses artificial intelligence (OpenAI GPT-4) to generate responses.
8
+ All AI-generated outputs are clearly labeled and subject to human review.
9
+
10
+ ## Data Governance
11
+
12
+ - All user data is anonymized before processing
13
+ - AI model inputs and outputs are logged for audit purposes
14
+ - Data retention policy: 90 days
@@ -0,0 +1,17 @@
1
+ import OpenAI from "openai";
2
+ import pino from "pino";
3
+
4
+ const logger = pino({ name: "ai-service" });
5
+ const openai = new OpenAI();
6
+
7
+ export async function generateResponse(prompt: string, userId: string) {
8
+ logger.info({ userId, action: "ai-request" }, "Processing AI request");
9
+
10
+ const response = await openai.chat.completions.create({
11
+ model: "gpt-4",
12
+ messages: [{ role: "user", content: prompt }],
13
+ });
14
+
15
+ logger.info({ userId, action: "ai-response", model: "gpt-4" }, "AI response generated");
16
+ return response;
17
+ }
@@ -0,0 +1,11 @@
1
+ export interface ReviewDecision {
2
+ approved: boolean;
3
+ reviewerId: string;
4
+ timestamp: Date;
5
+ overrideReason?: string;
6
+ }
7
+
8
+ export async function requireHumanApproval(_aiOutput: string): Promise<ReviewDecision> {
9
+ // Human review gate - AI output must be approved before action
10
+ return { approved: false, reviewerId: "", timestamp: new Date() };
11
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "compliant-ai-service",
3
+ "version": "2.0.0",
4
+ "dependencies": {
5
+ "openai": "^4.0.0",
6
+ "pino": "^8.0.0",
7
+ "@sentry/node": "^7.0.0",
8
+ "@euconform/core": "^1.0.0"
9
+ }
10
+ }