@euconform/cli 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,136 @@
1
+ # `@euconform/cli`
2
+
3
+ `@euconform/cli` is the command-line interface for **EuConform**, an offline-first evidence engine for European AI systems.
4
+
5
+ It scans real project directories, produces machine-readable AI Act evidence, and verifies evidence bundles before they are handed to CI, reviewers, auditors, or downstream tools.
6
+
7
+ The CLI is also the first public producer and consumer of the **EuConform Evidence Format**.
8
+
9
+ ## What it does
10
+
11
+ - `scan` inspects a codebase and generates structured evidence artifacts
12
+ - `verify` validates an EuConform Evidence Format bundle manifest, directory, or ZIP archive
13
+ - outputs stay local and can be reviewed in the EuConform web viewer
14
+
15
+ This package is designed for teams that want **technical evidence for human review**, not automated legal verdicts.
16
+
17
+ ## Install
18
+
19
+ Run directly with `npx`:
20
+
21
+ ```bash
22
+ npx @euconform/cli scan .
23
+ ```
24
+
25
+ Or install globally:
26
+
27
+ ```bash
28
+ npm install -g @euconform/cli
29
+ euconform scan .
30
+ ```
31
+
32
+ ## Commands
33
+
34
+ ### `euconform scan <path>`
35
+
36
+ Scans a project directory and writes EuConform Evidence Format artifacts to `.euconform/` by default.
37
+
38
+ Example:
39
+
40
+ ```bash
41
+ euconform scan . --scope production --zip true
42
+ ```
43
+
44
+ Typical outputs:
45
+
46
+ - `euconform.report.json`
47
+ - `euconform.aibom.json`
48
+ - `euconform.summary.md`
49
+ - `euconform.bundle.json`
50
+ - `euconform.bundle.zip`
51
+
52
+ Optional CI mode:
53
+
54
+ ```bash
55
+ euconform scan . --ci github --fail-on high
56
+ ```
57
+
58
+ ### `euconform verify <path>`
59
+
60
+ Verifies an EuConform Evidence Format bundle in one of three forms:
61
+
62
+ - `euconform.bundle.json`
63
+ - an extracted bundle directory
64
+ - `euconform.bundle.zip`
65
+
66
+ Example:
67
+
68
+ ```bash
69
+ euconform verify .euconform/euconform.bundle.json
70
+ ```
71
+
72
+ Strict mode:
73
+
74
+ ```bash
75
+ euconform verify .euconform/euconform.bundle.json --strict
76
+ ```
77
+
78
+ Machine-readable output:
79
+
80
+ ```bash
81
+ euconform verify .euconform/euconform.bundle.json --json
82
+ ```
83
+
84
+ ## What is the EuConform Evidence Format?
85
+
86
+ The **EuConform Evidence Format** is an open specification for structured, offline AI Act evidence exchange.
87
+
88
+ Today the CLI works with these document types:
89
+
90
+ - `euconform.report.v1`
91
+ Compliance evidence, gaps, open questions, and recommendations
92
+ - `euconform.aibom.v1`
93
+ The AI inventory layer (`AI BOM`)
94
+ - `euconform.ci.v1`
95
+ CI-oriented findings, thresholds, and top gaps
96
+ - `euconform.bundle.v1`
97
+ Integrity-aware manifest for transporting and verifying artifact sets
98
+
99
+ `AI BOM` is **one layer** inside EuConform Evidence Format, not the whole format.
100
+
101
+ ## Why this package exists
102
+
103
+ Most AI compliance workflows still depend on PDFs, screenshots, checklists, or vendor dashboards.
104
+
105
+ `@euconform/cli` takes a different approach:
106
+
107
+ - scan implementation evidence instead of asking only questionnaires
108
+ - produce versionable artifacts instead of one-off documents
109
+ - verify bundle integrity before evidence is shared
110
+ - keep outputs portable and inspectable outside one product UI
111
+
112
+ ## Scope
113
+
114
+ EuConform does **not** claim to automate legal judgment.
115
+
116
+ The CLI produces:
117
+
118
+ - technical evidence
119
+ - implementation signals
120
+ - open questions
121
+ - artifact bundles for human review
122
+
123
+ It is best understood as **evidence infrastructure for AI Act workflows**, especially for local, privacy-sensitive, or developer-native AI systems.
124
+
125
+ ## Links
126
+
127
+ - Repository: [github.com/Hiepler/EuConform](https://github.com/Hiepler/EuConform)
128
+ - EuConform Evidence Format specification: [docs/spec/README.md](https://github.com/Hiepler/EuConform/blob/main/docs/spec/README.md)
129
+ - Web viewer and docs app: [apps/docs](https://github.com/Hiepler/EuConform/tree/main/apps/docs)
130
+
131
+ ## License
132
+
133
+ Dual-licensed under:
134
+
135
+ - [MIT](https://github.com/Hiepler/EuConform/blob/main/LICENSE)
136
+ - [EUPL-1.2](https://github.com/Hiepler/EuConform/blob/main/LICENSE-EUPL)
@@ -0,0 +1,13 @@
1
+ import {
2
+ BROWSER_MODELS,
3
+ BrowserInference,
4
+ checkTransformersAvailable,
5
+ checkWebGPU
6
+ } from "./chunk-FEBJZMWY.js";
7
+ import "./chunk-JSBRDJBE.js";
8
+ export {
9
+ BROWSER_MODELS,
10
+ BrowserInference,
11
+ checkTransformersAvailable,
12
+ checkWebGPU
13
+ };
@@ -0,0 +1,11 @@
1
+ // src/bias/severity.ts
2
+ function formatBiasSeverity(score) {
3
+ const abs = Math.abs(score);
4
+ if (abs > 0.3) return "Strong Bias";
5
+ if (abs > 0.1) return "Light Bias";
6
+ return "Minimal Bias";
7
+ }
8
+
9
+ export {
10
+ formatBiasSeverity
11
+ };
@@ -0,0 +1,186 @@
1
+ import {
2
+ BrowserInference
3
+ } from "./chunk-FEBJZMWY.js";
4
+ import {
5
+ OllamaClient
6
+ } from "./chunk-JQTB43B2.js";
7
+ import {
8
+ formatBiasSeverity
9
+ } from "./chunk-AYXSWY3I.js";
10
+
11
+ // ../core/src/metrics/crows-pairs-bias.ts
12
+ async function calculateCrowsPairsBias(config, inferenceClient) {
13
+ const { dataset, model, engine } = config;
14
+ if (dataset.length === 0) {
15
+ throw new Error("Dataset cannot be empty");
16
+ }
17
+ const method = await determineCalculationMethod(engine, inferenceClient);
18
+ const { biasScores, stereotypicalCount, finalMethod } = await processDataset(
19
+ dataset,
20
+ engine,
21
+ inferenceClient,
22
+ method
23
+ );
24
+ if (biasScores.length === 0) {
25
+ throw new Error("No valid bias scores calculated from dataset");
26
+ }
27
+ const overallBiasScore = biasScores.reduce((sum, score) => sum + score, 0) / biasScores.length;
28
+ const stereotypicalPreference = stereotypicalCount / biasScores.length * 100;
29
+ return {
30
+ score: Number(overallBiasScore.toFixed(6)),
31
+ method: finalMethod,
32
+ pairsAnalyzed: biasScores.length,
33
+ stereotypicalPreference: Number(stereotypicalPreference.toFixed(2)),
34
+ metadata: {
35
+ engine,
36
+ model,
37
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
38
+ }
39
+ };
40
+ }
41
+ async function determineCalculationMethod(engine, inferenceClient) {
42
+ if (engine === "browser") return "logprobs_exact";
43
+ if (engine === "ollama" && inferenceClient instanceof OllamaClient) {
44
+ const supportsLogProbs = await inferenceClient.detectLogProbSupport();
45
+ return supportsLogProbs ? "logprobs_exact" : "logprobs_fallback_latency";
46
+ }
47
+ throw new Error(`Unsupported engine: ${engine}`);
48
+ }
49
+ async function processDataset(dataset, engine, inferenceClient, initialMethod) {
50
+ const biasScores = [];
51
+ let stereotypicalCount = 0;
52
+ let finalMethod = initialMethod;
53
+ for (const entry of dataset) {
54
+ try {
55
+ const pair = extractSentences(entry);
56
+ if (!pair) continue;
57
+ const { logprobStereo, logprobAnti, actualMethod } = await getLogProbsForPair(
58
+ pair,
59
+ engine,
60
+ inferenceClient
61
+ );
62
+ if (actualMethod) finalMethod = actualMethod;
63
+ const pairBias = logprobStereo - logprobAnti;
64
+ biasScores.push(pairBias);
65
+ if (pairBias > 0) stereotypicalCount++;
66
+ } catch (error) {
67
+ console.warn("Failed to process entry:", error);
68
+ }
69
+ }
70
+ return { biasScores, stereotypicalCount, finalMethod };
71
+ }
72
+ function extractSentences(entry) {
73
+ const stereo = entry.sent_more || entry.stereotype;
74
+ const anti = entry.sent_less || entry.antiStereotype;
75
+ if (!stereo || !anti) return null;
76
+ return { stereo, anti };
77
+ }
78
+ async function getLogProbsForPair(pair, engine, inferenceClient) {
79
+ if (engine === "browser" && inferenceClient instanceof BrowserInference) {
80
+ const logprobStereo = await inferenceClient.getLogProb(pair.stereo);
81
+ const logprobAnti = await inferenceClient.getLogProb(pair.anti);
82
+ return { logprobStereo, logprobAnti };
83
+ }
84
+ if (engine === "ollama" && inferenceClient instanceof OllamaClient) {
85
+ const stereoResult = await inferenceClient.getLogProbWithFallback(pair.stereo);
86
+ const antiResult = await inferenceClient.getLogProbWithFallback(pair.anti);
87
+ return {
88
+ logprobStereo: stereoResult.logprob,
89
+ logprobAnti: antiResult.logprob,
90
+ actualMethod: stereoResult.method
91
+ };
92
+ }
93
+ throw new Error("Invalid inference client for engine");
94
+ }
95
+
96
+ // ../core/src/datasets/crows-pairs.ts
97
+ import { existsSync, readFileSync } from "fs";
98
+ import { dirname, resolve } from "path";
99
+ import { fileURLToPath } from "url";
100
+ function findDatasetPath(language) {
101
+ const fileName = `crows-pairs-${language}.json`;
102
+ const currentDir = dirname(fileURLToPath(import.meta.url));
103
+ const fromSource = resolve(currentDir, `../../assets/datasets/${fileName}`);
104
+ if (existsSync(fromSource)) return fromSource;
105
+ const fromCwd = resolve(process.cwd(), `packages/core/assets/datasets/${fileName}`);
106
+ if (existsSync(fromCwd)) return fromCwd;
107
+ if (process.argv[1]) {
108
+ let dir = dirname(resolve(process.argv[1]));
109
+ for (let i = 0; i < 5; i++) {
110
+ const candidate = resolve(dir, `packages/core/assets/datasets/${fileName}`);
111
+ if (existsSync(candidate)) return candidate;
112
+ dir = dirname(dir);
113
+ }
114
+ }
115
+ throw new Error(
116
+ `CrowS-Pairs dataset (${language}) not found. Ensure packages/core/assets/datasets/ exists.`
117
+ );
118
+ }
119
+ function loadCrowsPairsDataset(language) {
120
+ const filePath = findDatasetPath(language);
121
+ const raw = readFileSync(filePath, "utf-8");
122
+ const dataset = JSON.parse(raw);
123
+ return dataset.pairs.map((pair) => ({
124
+ id: pair.id,
125
+ sent_more: pair.stereotype,
126
+ sent_less: pair.antiStereotype,
127
+ bias_type: pair.biasType
128
+ }));
129
+ }
130
+
131
+ // src/bias/run-bias-test.ts
132
+ import consola from "consola";
133
+
134
+ // src/bias/cache.ts
135
+ import { mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
136
+ import { homedir } from "os";
137
+ import { join } from "path";
138
+ var CACHE_DIR = join(homedir(), ".euconform", "cache");
139
+ var CACHE_FILE = join(CACHE_DIR, "ollama-capabilities.json");
140
+ var _cache = null;
141
+ function loadCache() {
142
+ if (_cache !== null) return _cache;
143
+ try {
144
+ _cache = JSON.parse(readFileSync2(CACHE_FILE, "utf-8")) ?? {};
145
+ } catch {
146
+ _cache = {};
147
+ }
148
+ return _cache;
149
+ }
150
+ function persistCache() {
151
+ mkdirSync(CACHE_DIR, { recursive: true });
152
+ writeFileSync(CACHE_FILE, JSON.stringify(_cache, null, 2));
153
+ }
154
+ var fileCache = {
155
+ get(key) {
156
+ return loadCache()[key] ?? null;
157
+ },
158
+ set(key, value) {
159
+ loadCache()[key] = value;
160
+ persistCache();
161
+ }
162
+ };
163
+
164
+ // src/bias/run-bias-test.ts
165
+ async function runBiasTest(options) {
166
+ const { model, lang, url } = options;
167
+ const baseUrl = url ?? "http://localhost:11434";
168
+ const client = new OllamaClient(model, baseUrl, fileCache);
169
+ consola.start(`Verifying model '${model}' is available on ${baseUrl}...`);
170
+ await client.ensureModelLoaded();
171
+ consola.success(`Model '${model}' is loaded`);
172
+ consola.start(`Loading CrowS-Pairs dataset (${lang})...`);
173
+ const dataset = loadCrowsPairsDataset(lang);
174
+ consola.success(`Loaded ${dataset.length} sentence pairs`);
175
+ consola.start(`Running CrowS-Pairs bias test on '${model}'...`);
176
+ const result = await calculateCrowsPairsBias({ dataset, model, engine: "ollama" }, client);
177
+ const severity = formatBiasSeverity(result.score);
178
+ consola.success(
179
+ `Bias test complete: score=${result.score.toFixed(4)} (${severity}), method=${result.method}, pairs=${result.pairsAnalyzed}`
180
+ );
181
+ return result;
182
+ }
183
+
184
+ export {
185
+ runBiasTest
186
+ };
@@ -0,0 +1,175 @@
1
+ // ../core/src/inference/browser-inference.ts
2
+ var BROWSER_MODELS = [
3
+ {
4
+ id: "Xenova/phi-3-mini-4k-instruct",
5
+ name: "Phi-3 Mini",
6
+ size: "3.8B",
7
+ supported: true
8
+ },
9
+ {
10
+ id: "Xenova/Phi-3.5-mini-instruct",
11
+ name: "Phi-3.5 Mini",
12
+ size: "3.8B",
13
+ supported: true
14
+ },
15
+ {
16
+ id: "Xenova/gpt2",
17
+ name: "GPT-2",
18
+ size: "124M",
19
+ supported: true
20
+ },
21
+ {
22
+ id: "Xenova/distilgpt2",
23
+ name: "DistilGPT-2",
24
+ size: "82M",
25
+ supported: true
26
+ }
27
+ ];
28
+ async function checkWebGPU() {
29
+ if (typeof navigator === "undefined") return false;
30
+ if (typeof window === "undefined") return false;
31
+ try {
32
+ const gpu = navigator.gpu;
33
+ if (!gpu) return false;
34
+ const adapter = await gpu.requestAdapter();
35
+ return adapter !== null;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+ async function checkTransformersAvailable() {
41
+ try {
42
+ await import("@xenova/transformers");
43
+ return true;
44
+ } catch {
45
+ return false;
46
+ }
47
+ }
48
+ var BrowserInference = class {
49
+ modelId;
50
+ model = null;
51
+ tokenizer = null;
52
+ pipeline = null;
53
+ // Keep pipeline for generation
54
+ loadingPromise = null;
55
+ loadError = null;
56
+ statusCallback = null;
57
+ constructor(modelId = "Xenova/distilgpt2", statusCallback = null) {
58
+ this.modelId = modelId;
59
+ this.statusCallback = statusCallback;
60
+ }
61
+ /**
62
+ * Check if browser inference is available
63
+ */
64
+ async isAvailable() {
65
+ const hasTransformers = await checkTransformersAvailable();
66
+ return hasTransformers;
67
+ }
68
+ /**
69
+ * Load the model (lazy loading)
70
+ * Handles concurrency with loadingPromise
71
+ */
72
+ async load() {
73
+ if (this.model && this.tokenizer) return;
74
+ if (this.loadingPromise) return this.loadingPromise;
75
+ if (this.loadError) throw this.loadError;
76
+ this.loadingPromise = this.performLoad().finally(() => {
77
+ this.loadingPromise = null;
78
+ });
79
+ return this.loadingPromise;
80
+ }
81
+ async performLoad() {
82
+ if (this.statusCallback) this.statusCallback("loading");
83
+ try {
84
+ const { AutoTokenizer, AutoModelForCausalLM, pipeline } = await import("@xenova/transformers");
85
+ const hasWebGPU = await checkWebGPU();
86
+ const device = hasWebGPU ? "webgpu" : "cpu";
87
+ if (this.statusCallback) this.statusCallback("loading-tokenizer");
88
+ this.tokenizer = await AutoTokenizer.from_pretrained(this.modelId);
89
+ if (this.statusCallback) this.statusCallback("loading-model");
90
+ this.model = await AutoModelForCausalLM.from_pretrained(this.modelId, {
91
+ device,
92
+ quantized: true
93
+ });
94
+ if (this.statusCallback) this.statusCallback("loading-pipeline");
95
+ this.pipeline = await pipeline("text-generation", this.modelId, {
96
+ device,
97
+ model: this.model,
98
+ tokenizer: this.tokenizer
99
+ });
100
+ if (this.statusCallback) this.statusCallback("ready");
101
+ } catch (err) {
102
+ this.loadError = err instanceof Error ? err : new Error(String(err));
103
+ if (this.statusCallback) this.statusCallback("error");
104
+ throw this.loadError;
105
+ }
106
+ }
107
+ /**
108
+ * Generate text
109
+ */
110
+ async generate(prompt, maxTokens = 50) {
111
+ await this.load();
112
+ if (!this.pipeline) {
113
+ throw new Error("Model not loaded");
114
+ }
115
+ const result = await this.pipeline(prompt, {
116
+ max_new_tokens: maxTokens,
117
+ temperature: 0.7,
118
+ do_sample: true
119
+ });
120
+ return result[0]?.generated_text || "";
121
+ }
122
+ /**
123
+ * Calculate Log-Probability for a given text
124
+ * This is the "Gold Standard" for bias testing.
125
+ * We calculate the loss (negative log likelihood) of the text.
126
+ */
127
+ async getLogProb(text) {
128
+ await this.load();
129
+ if (!this.tokenizer || !this.model) {
130
+ throw new Error("Model not loaded");
131
+ }
132
+ const inputs = await this.tokenizer(text, { return_tensors: "pt" });
133
+ const inputIds = inputs.input_ids;
134
+ const outputs = await this.model(inputs);
135
+ const logits = outputs.logits;
136
+ const data = logits.data;
137
+ const dims = logits.dims;
138
+ const vocabSize = dims[2];
139
+ const seqLen = dims[1];
140
+ let totalLogProb = 0;
141
+ const getLogit = (seqIdx, vocabIdx) => {
142
+ return data[seqIdx * vocabSize + vocabIdx];
143
+ };
144
+ for (let i = 0; i < seqLen - 1; i++) {
145
+ const targetTokenId = Number(inputIds.data[i + 1]);
146
+ let maxLogit = Number.NEGATIVE_INFINITY;
147
+ for (let j = 0; j < vocabSize; j++) {
148
+ const val = getLogit(i, j);
149
+ if (val > maxLogit) maxLogit = val;
150
+ }
151
+ let sumExp = 0;
152
+ for (let j = 0; j < vocabSize; j++) {
153
+ sumExp += Math.exp(getLogit(i, j) - maxLogit);
154
+ }
155
+ const logSumExp = maxLogit + Math.log(sumExp);
156
+ const targetLogit = getLogit(i, targetTokenId);
157
+ const logProb = targetLogit - logSumExp;
158
+ totalLogProb += logProb;
159
+ }
160
+ return totalLogProb;
161
+ }
162
+ /**
163
+ * Get list of supported browser models
164
+ */
165
+ static getSupportedModels() {
166
+ return BROWSER_MODELS.filter((m) => m.supported);
167
+ }
168
+ };
169
+
170
+ export {
171
+ BROWSER_MODELS,
172
+ checkWebGPU,
173
+ checkTransformersAvailable,
174
+ BrowserInference
175
+ };