@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
@@ -0,0 +1,45 @@
1
+ import type { ConfidenceLevel } from "../../evidence/types";
2
+
3
+ /** Source-code extensions used by ai-provider, ai-framework, rag, compliance detectors */
4
+ export const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
5
+
6
+ /** Extended set including config formats, used by framework and local-inference detectors */
7
+ export const EXTENDED_EXTENSIONS = new Set([
8
+ ".ts",
9
+ ".tsx",
10
+ ".js",
11
+ ".jsx",
12
+ ".json",
13
+ ".yaml",
14
+ ".yml",
15
+ ]);
16
+
17
+ /** Standardized snippet truncation length */
18
+ export const MAX_SNIPPET_LENGTH = 200;
19
+
20
+ /** Numeric ranking for confidence levels (higher = more confident) */
21
+ export const CONFIDENCE_RANK: Record<ConfidenceLevel, number> = {
22
+ high: 2,
23
+ medium: 1,
24
+ low: 0,
25
+ };
26
+
27
+ /** Return the higher of two confidence levels */
28
+ export function higherConfidence(a: ConfidenceLevel, b: ConfidenceLevel): ConfidenceLevel {
29
+ return CONFIDENCE_RANK[a] >= CONFIDENCE_RANK[b] ? a : b;
30
+ }
31
+
32
+ /** Escape special regex characters in a string */
33
+ export function escapeRegex(str: string): string {
34
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
35
+ }
36
+
37
+ /** Check whether an extension is a standard source file */
38
+ export function isSourceFile(ext: string): boolean {
39
+ return SOURCE_EXTENSIONS.has(ext);
40
+ }
41
+
42
+ /** Check whether an extension is in the extended (source + config) set */
43
+ export function isExtendedSourceFile(ext: string): boolean {
44
+ return EXTENDED_EXTENSIONS.has(ext);
45
+ }
@@ -0,0 +1,290 @@
1
+ /**
2
+ * File discovery for the EuConform scanner.
3
+ *
4
+ * Walks a target directory, respects .gitignore, filters by extension
5
+ * and named files, and returns file contents for pattern matching.
6
+ */
7
+
8
+ import { readFile, readdir, stat } from "node:fs/promises";
9
+ import { basename, extname, join, resolve } from "node:path";
10
+ import ignore from "ignore";
11
+ import type { FileProvenance, ScanFile, ScanOptions, ScanScope } from "../evidence/types";
12
+
13
+ const DEFAULT_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".yaml", ".yml"];
14
+
15
+ /** Named files to always include regardless of extension */
16
+ const NAMED_FILE_PATTERNS = [
17
+ /^package\.json$/,
18
+ /^pnpm-lock\.yaml$/,
19
+ /^package-lock\.json$/,
20
+ /^yarn\.lock$/,
21
+ /^\.env\.example$/,
22
+ /^\.env\.sample$/,
23
+ /^next\.config\./,
24
+ /^vite\.config\./,
25
+ /^docker-compose/,
26
+ /^Dockerfile/,
27
+ /^tsconfig.*\.json$/,
28
+ ];
29
+
30
+ const ALWAYS_SKIP_DIRS = [
31
+ "node_modules",
32
+ ".git",
33
+ "dist",
34
+ ".next",
35
+ "build",
36
+ "coverage",
37
+ ".turbo",
38
+ ".cache",
39
+ ".euconform",
40
+ ];
41
+
42
+ const DEFAULT_MAX_FILE_SIZE = 1_048_576; // 1 MB
43
+ const DEFAULT_MAX_FILES = 10_000;
44
+ const DEFAULT_SCOPE: ScanScope = "production";
45
+
46
+ const PRODUCTION_SKIP_PROVENANCE = new Set<FileProvenance>([
47
+ "test",
48
+ "fixture",
49
+ "example",
50
+ "generated",
51
+ ]);
52
+
53
+ const TEST_SEGMENTS = new Set(["test", "tests", "__test__", "__tests__", "spec", "__spec__"]);
54
+ const FIXTURE_SEGMENTS = new Set([
55
+ "fixture",
56
+ "fixtures",
57
+ "__fixture__",
58
+ "__fixtures__",
59
+ "mocks",
60
+ "__mocks__",
61
+ "mock-data",
62
+ ]);
63
+ const EXAMPLE_SEGMENTS = new Set(["example", "examples", "sample", "samples", "demo", "demos"]);
64
+ const GENERATED_SEGMENTS = new Set(["generated", "__generated__", ".generated", "gen"]);
65
+ const TOOLING_SEGMENTS = new Set([".github", "scripts", "tools", "tooling", "bin"]);
66
+
67
+ const TEST_FILE_PATTERN = /(?:^|\.)(?:test|spec)\.[^.]+$/i;
68
+ const GENERATED_FILE_PATTERN = /(?:^|\.)(?:generated|min)\.[^.]+$/i;
69
+
70
+ const BINARY_EXTENSIONS = new Set([
71
+ ".png",
72
+ ".jpg",
73
+ ".jpeg",
74
+ ".gif",
75
+ ".ico",
76
+ ".svg",
77
+ ".woff",
78
+ ".woff2",
79
+ ".ttf",
80
+ ".eot",
81
+ ".zip",
82
+ ".tar",
83
+ ".gz",
84
+ ".br",
85
+ ".pdf",
86
+ ".doc",
87
+ ".docx",
88
+ ".mp3",
89
+ ".mp4",
90
+ ".webm",
91
+ ".ogg",
92
+ ".wasm",
93
+ ".node",
94
+ ".dylib",
95
+ ".so",
96
+ ".lock", // bun.lockb is binary
97
+ ]);
98
+
99
+ function isBinaryExtension(ext: string): boolean {
100
+ return BINARY_EXTENSIONS.has(ext);
101
+ }
102
+
103
+ function isNamedFile(fileName: string): boolean {
104
+ return NAMED_FILE_PATTERNS.some((pattern) => pattern.test(fileName));
105
+ }
106
+
107
+ function shouldIncludeFile(fileName: string, ext: string, extensions: string[]): boolean {
108
+ if (isNamedFile(fileName)) return true;
109
+ if (isBinaryExtension(ext)) return false;
110
+ return extensions.includes(ext);
111
+ }
112
+
113
+ export function classifyFileProvenance(relativePath: string): FileProvenance {
114
+ const normalized = relativePath.replace(/\\/g, "/");
115
+ const rawSegments = normalized.split("/").filter(Boolean);
116
+ const segments = rawSegments.map((segment) => segment.toLowerCase());
117
+ const rawFileName = rawSegments[rawSegments.length - 1] ?? "";
118
+ const fileName = segments[segments.length - 1] ?? "";
119
+ const ext = extname(fileName);
120
+ const isRootFile = segments.length === 1;
121
+
122
+ if (segments.some((segment) => FIXTURE_SEGMENTS.has(segment))) return "fixture";
123
+ if (segments.some((segment) => TEST_SEGMENTS.has(segment)) || TEST_FILE_PATTERN.test(fileName)) {
124
+ return "test";
125
+ }
126
+ if (segments.some((segment) => EXAMPLE_SEGMENTS.has(segment))) return "example";
127
+ if (
128
+ segments.some((segment) => GENERATED_SEGMENTS.has(segment)) ||
129
+ GENERATED_FILE_PATTERN.test(fileName)
130
+ ) {
131
+ return "generated";
132
+ }
133
+ if (segments.some((segment) => TOOLING_SEGMENTS.has(segment))) return "tooling";
134
+
135
+ if (fileName.startsWith("readme") && isRootFile) return "root-docs";
136
+ if (ext === ".md") return "docs";
137
+
138
+ if (
139
+ isNamedFile(rawFileName) ||
140
+ ext === ".yaml" ||
141
+ ext === ".yml" ||
142
+ fileName.startsWith(".env") ||
143
+ /\.config\.[^.]+$/i.test(rawFileName)
144
+ ) {
145
+ return "config";
146
+ }
147
+
148
+ return "runtime";
149
+ }
150
+
151
+ async function loadGitignore(targetPath: string): Promise<ReturnType<typeof ignore>> {
152
+ const ig = ignore();
153
+ try {
154
+ const content = await readFile(join(targetPath, ".gitignore"), "utf-8");
155
+ ig.add(content);
156
+ } catch {
157
+ // no .gitignore — that's fine
158
+ }
159
+ // Always ignore these regardless
160
+ ig.add(ALWAYS_SKIP_DIRS.map((d) => `${d}/`));
161
+ return ig;
162
+ }
163
+
164
+ export interface DiscoveryResult {
165
+ files: ScanFile[];
166
+ skippedCount: number;
167
+ }
168
+
169
+ interface FileFilterOptions {
170
+ scope: ScanScope;
171
+ extensions: string[];
172
+ maxSize: number;
173
+ }
174
+
175
+ function shouldProcessEntry(
176
+ entry: string,
177
+ targetPath: string,
178
+ filterOpts: FileFilterOptions
179
+ ): { absolutePath: string; ext: string; provenance: FileProvenance } | "skip" | "ignore" {
180
+ const absolutePath = join(targetPath, entry);
181
+ const fileName = basename(entry);
182
+ const ext = extname(fileName).toLowerCase();
183
+ const provenance = classifyFileProvenance(entry);
184
+
185
+ if (filterOpts.scope === "production" && PRODUCTION_SKIP_PROVENANCE.has(provenance)) {
186
+ return "skip";
187
+ }
188
+ if (!shouldIncludeFile(fileName, ext, filterOpts.extensions)) return "ignore";
189
+
190
+ return { absolutePath, ext, provenance };
191
+ }
192
+
193
+ async function tryReadFile(
194
+ absolutePath: string,
195
+ entry: string,
196
+ ext: string,
197
+ provenance: FileProvenance,
198
+ maxSize: number
199
+ ): Promise<ScanFile | "skip" | "ignore"> {
200
+ try {
201
+ const fileStat = await stat(absolutePath);
202
+ if (!fileStat.isFile() || fileStat.size === 0) return "ignore";
203
+ if (fileStat.size > maxSize) return "skip";
204
+
205
+ const content = await readFile(absolutePath, "utf-8");
206
+ return {
207
+ relativePath: entry,
208
+ absolutePath,
209
+ extension: ext,
210
+ content,
211
+ sizeBytes: fileStat.size,
212
+ provenance,
213
+ };
214
+ } catch {
215
+ return "skip";
216
+ }
217
+ }
218
+
219
+ function resolveOptions(options: ScanOptions) {
220
+ return {
221
+ targetPath: resolve(options.targetPath),
222
+ scope: options.scope ?? DEFAULT_SCOPE,
223
+ extensions: options.extensions ?? DEFAULT_EXTENSIONS,
224
+ maxSize: options.maxFileSizeBytes ?? DEFAULT_MAX_FILE_SIZE,
225
+ maxFiles: options.maxFiles ?? DEFAULT_MAX_FILES,
226
+ excludeGlobs: options.excludeGlobs,
227
+ };
228
+ }
229
+
230
+ async function readDirectoryEntries(targetPath: string): Promise<string[]> {
231
+ try {
232
+ return (await readdir(targetPath, { recursive: true, encoding: "utf-8" })) as string[];
233
+ } catch (err) {
234
+ throw new Error(`Cannot read directory: ${targetPath}: ${err}`);
235
+ }
236
+ }
237
+
238
+ function processEntry(
239
+ entry: string,
240
+ ig: ReturnType<typeof ignore>,
241
+ targetPath: string,
242
+ filterOpts: FileFilterOptions
243
+ ): "skip" | "ignore" | { absolutePath: string; ext: string; provenance: FileProvenance } {
244
+ if (ig.ignores(entry)) return "ignore";
245
+ return shouldProcessEntry(entry, targetPath, filterOpts);
246
+ }
247
+
248
+ export async function discoverFiles(options: ScanOptions): Promise<DiscoveryResult> {
249
+ const { targetPath, scope, extensions, maxSize, maxFiles, excludeGlobs } =
250
+ resolveOptions(options);
251
+
252
+ const ig = await loadGitignore(targetPath);
253
+ if (excludeGlobs?.length) ig.add(excludeGlobs);
254
+
255
+ const entries = await readDirectoryEntries(targetPath);
256
+ const filterOpts: FileFilterOptions = { scope, extensions, maxSize };
257
+ const files: ScanFile[] = [];
258
+ let skippedCount = 0;
259
+
260
+ for (const entry of entries) {
261
+ if (files.length >= maxFiles) {
262
+ skippedCount++;
263
+ continue;
264
+ }
265
+
266
+ const processed = processEntry(entry, ig, targetPath, filterOpts);
267
+ if (processed === "skip") {
268
+ skippedCount++;
269
+ continue;
270
+ }
271
+ if (processed === "ignore") continue;
272
+
273
+ const result = await tryReadFile(
274
+ processed.absolutePath,
275
+ entry,
276
+ processed.ext,
277
+ processed.provenance,
278
+ maxSize
279
+ );
280
+ if (result === "skip") {
281
+ skippedCount++;
282
+ continue;
283
+ }
284
+ if (result === "ignore") continue;
285
+
286
+ files.push(result);
287
+ }
288
+
289
+ return { files, skippedCount };
290
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @euconform/core/scanner — Local repository scanner
3
+ *
4
+ * Scans a project directory for AI components, compliance signals,
5
+ * and regulatory hints. Produces evidence, not verdicts.
6
+ */
7
+
8
+ export { scanRepository } from "./aggregator";
9
+ export { discoverFiles } from "./discovery";
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildBundleManifest } from "../../src/evidence/bundle";
3
+
4
+ const REPORT_CONTENT = JSON.stringify({
5
+ schemaVersion: "euconform.report.v1",
6
+ generatedAt: "2026-01-01T00:00:00Z",
7
+ });
8
+
9
+ const AIBOM_CONTENT = JSON.stringify({
10
+ schemaVersion: "euconform.aibom.v1",
11
+ generatedAt: "2026-01-01T00:00:00Z",
12
+ });
13
+
14
+ const CI_CONTENT = JSON.stringify({
15
+ schemaVersion: "euconform.ci.v1",
16
+ generatedAt: "2026-01-01T00:00:00Z",
17
+ });
18
+
19
+ const SUMMARY_CONTENT = "# EuConform Scan Report\n\nSummary here.";
20
+
21
+ const BASE_OPTS = {
22
+ tool: { name: "euconform", version: "1.0.0" },
23
+ target: { name: "test-project", rootPath: "/test" },
24
+ generatedAt: "2026-01-01T00:00:00Z",
25
+ };
26
+
27
+ describe("buildBundleManifest", () => {
28
+ it("produces a valid bundle with all artifacts", () => {
29
+ const bundle = buildBundleManifest({
30
+ ...BASE_OPTS,
31
+ report: { content: REPORT_CONTENT, fileName: "euconform.report.json" },
32
+ aibom: { content: AIBOM_CONTENT, fileName: "euconform.aibom.json" },
33
+ ci: { content: CI_CONTENT, fileName: "euconform.ci.json" },
34
+ summary: { content: SUMMARY_CONTENT, fileName: "euconform.summary.md" },
35
+ });
36
+
37
+ expect(bundle.schemaVersion).toBe("euconform.bundle.v1");
38
+ expect(bundle.generatedAt).toBe("2026-01-01T00:00:00Z");
39
+ expect(bundle.tool).toEqual({ name: "euconform", version: "1.0.0" });
40
+ expect(bundle.target).toEqual({ name: "test-project", rootPath: "/test" });
41
+ expect(bundle.artifacts).toHaveLength(4);
42
+ });
43
+
44
+ it("sets report as required and others as optional", () => {
45
+ const bundle = buildBundleManifest({
46
+ ...BASE_OPTS,
47
+ report: { content: REPORT_CONTENT, fileName: "euconform.report.json" },
48
+ aibom: { content: AIBOM_CONTENT, fileName: "euconform.aibom.json" },
49
+ });
50
+
51
+ const report = bundle.artifacts.find((a) => a.role === "report");
52
+ const aibom = bundle.artifacts.find((a) => a.role === "aibom");
53
+ expect(report?.required).toBe(true);
54
+ expect(aibom?.required).toBe(false);
55
+ });
56
+
57
+ it("produces valid SHA-256 hex hashes", () => {
58
+ const bundle = buildBundleManifest({
59
+ ...BASE_OPTS,
60
+ report: { content: REPORT_CONTENT, fileName: "euconform.report.json" },
61
+ });
62
+
63
+ for (const artifact of bundle.artifacts) {
64
+ expect(artifact.sha256).toMatch(/^[a-f0-9]{64}$/);
65
+ }
66
+ });
67
+
68
+ it("extracts schemaVersion from JSON artifacts", () => {
69
+ const bundle = buildBundleManifest({
70
+ ...BASE_OPTS,
71
+ report: { content: REPORT_CONTENT, fileName: "euconform.report.json" },
72
+ aibom: { content: AIBOM_CONTENT, fileName: "euconform.aibom.json" },
73
+ ci: { content: CI_CONTENT, fileName: "euconform.ci.json" },
74
+ });
75
+
76
+ expect(bundle.artifacts.find((a) => a.role === "report")?.schemaVersion).toBe(
77
+ "euconform.report.v1"
78
+ );
79
+ expect(bundle.artifacts.find((a) => a.role === "aibom")?.schemaVersion).toBe(
80
+ "euconform.aibom.v1"
81
+ );
82
+ expect(bundle.artifacts.find((a) => a.role === "ci")?.schemaVersion).toBe("euconform.ci.v1");
83
+ });
84
+
85
+ it("sets mimeType for non-JSON artifacts", () => {
86
+ const bundle = buildBundleManifest({
87
+ ...BASE_OPTS,
88
+ report: { content: REPORT_CONTENT, fileName: "euconform.report.json" },
89
+ summary: { content: SUMMARY_CONTENT, fileName: "euconform.summary.md" },
90
+ });
91
+
92
+ const summary = bundle.artifacts.find((a) => a.role === "summary");
93
+ expect(summary?.mimeType).toBe("text/markdown");
94
+ expect(summary?.schemaVersion).toBeUndefined();
95
+
96
+ const report = bundle.artifacts.find((a) => a.role === "report");
97
+ expect(report?.mimeType).toBeUndefined();
98
+ });
99
+
100
+ it("excludes missing optional artifacts", () => {
101
+ const bundle = buildBundleManifest({
102
+ ...BASE_OPTS,
103
+ report: { content: REPORT_CONTENT, fileName: "euconform.report.json" },
104
+ });
105
+
106
+ expect(bundle.artifacts).toHaveLength(1);
107
+ expect(bundle.artifacts[0]?.role).toBe("report");
108
+ });
109
+
110
+ it("produces different hashes for different content", () => {
111
+ const bundle1 = buildBundleManifest({
112
+ ...BASE_OPTS,
113
+ report: {
114
+ content: '{"schemaVersion":"euconform.report.v1","a":1}',
115
+ fileName: "euconform.report.json",
116
+ },
117
+ });
118
+ const bundle2 = buildBundleManifest({
119
+ ...BASE_OPTS,
120
+ report: {
121
+ content: '{"schemaVersion":"euconform.report.v1","a":2}',
122
+ fileName: "euconform.report.json",
123
+ },
124
+ });
125
+
126
+ expect(bundle1.artifacts[0]?.sha256).not.toBe(bundle2.artifacts[0]?.sha256);
127
+ });
128
+ });