@awak-app/simy-cli 0.2.3 → 0.4.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.
@@ -0,0 +1,299 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ const MAX_FILE_BYTES = 1024 * 1024;
6
+ const MAX_FILES_PER_PATTERN = 5_000;
7
+
8
+ export async function executeKnowledgeBundle(bundle, repository) {
9
+ return (await evaluateKnowledgeBundle(bundle, repository)).findings;
10
+ }
11
+
12
+ export async function evaluateKnowledgeBundle(bundle, repository) {
13
+ const findings = [];
14
+ const modules = [];
15
+ for (const module of bundle.modules) {
16
+ if (["candidate", "retired"].includes(module.lifecycle.status)) continue;
17
+ if (!moduleApplies(module, repository.repository)) continue;
18
+ if (!(await matchModule(module, repository))) continue;
19
+ const exceptions = activeExceptionPatterns(module);
20
+ const ruleResults = [];
21
+ for (const rule of module.detection.checks) {
22
+ const violations = await executeRule(rule, repository);
23
+ const accepted = violations.filter((violation) => !exceptions.some((glob) => globMatches(glob, violation.file)));
24
+ ruleResults.push({ rule_id: rule.id, status: accepted.length ? "failed" : "passed", finding_count: accepted.length });
25
+ for (const violation of accepted) {
26
+ const evidenceHash = sha256(violation.evidence);
27
+ const findingId = sha256([bundle.integrity.digest, module.id, rule.id, violation.file || "repository", violation.line ?? "", violation.side || "", evidenceHash].join("\0"));
28
+ const assurance = assuranceContext(module, rule.id);
29
+ findings.push({
30
+ finding_id: `sqm:${findingId}`,
31
+ module_id: module.id,
32
+ module_version: module.version,
33
+ rule_id: rule.id,
34
+ lifecycle: module.lifecycle.status,
35
+ severity: module.lifecycle.severity,
36
+ message: rule.message,
37
+ invariant: module.invariant.statement,
38
+ remediation: module.invariant.remediation ?? null,
39
+ incident_refs: [...module.incident_refs],
40
+ file: violation.file,
41
+ line: violation.line ?? null,
42
+ side: violation.side ?? null,
43
+ evidence_hash: `sha256:${evidenceHash}`,
44
+ bundle_id: bundle.bundle_id,
45
+ bundle_version: bundle.version,
46
+ bundle_digest: bundle.integrity.digest,
47
+ state_model_ids: assurance.state_model_ids,
48
+ transition_ids: assurance.transition_ids,
49
+ stressor_ids: assurance.stressor_ids,
50
+ strength_ids: assurance.strength_ids,
51
+ scenario_ids: assurance.scenario_ids,
52
+ });
53
+ }
54
+ }
55
+ modules.push(evaluateAssurance(module, ruleResults));
56
+ }
57
+ return { findings, modules };
58
+ }
59
+
60
+ function evaluateAssurance(module, ruleResults) {
61
+ const failed = new Set(ruleResults.filter((result) => result.status === "failed").map((result) => result.rule_id));
62
+ const assurance = module.assurance;
63
+ if (!assurance) {
64
+ return {
65
+ module_id: module.id,
66
+ module_version: module.version,
67
+ lifecycle: module.lifecycle.status,
68
+ severity: module.lifecycle.severity,
69
+ rule_results: ruleResults,
70
+ state_models: [],
71
+ stressors: [],
72
+ strengths: [],
73
+ scenarios: [],
74
+ };
75
+ }
76
+ const stressors = assurance.stressors.map((stressor) => ({
77
+ id: stressor.id,
78
+ status: stressor.activation_check_refs.some((ref) => failed.has(ref)) ? "activated" : "not_activated",
79
+ check_refs: [...stressor.activation_check_refs],
80
+ }));
81
+ const strengths = assurance.strengths.map((strength) => ({
82
+ id: strength.id,
83
+ status: strength.check_refs.some((ref) => failed.has(ref)) ? "failed" : "passed",
84
+ check_refs: [...strength.check_refs],
85
+ }));
86
+ const strengthStatus = new Map(strengths.map((strength) => [strength.id, strength.status]));
87
+ const scenarios = assurance.scenarios.map((scenario) => {
88
+ let status = scenario.execution_requirement;
89
+ if (scenario.execution_requirement === "local") {
90
+ const checkFailed = scenario.check_refs.some((ref) => failed.has(ref));
91
+ const strengthFailed = scenario.expected_strength_refs.some((ref) => strengthStatus.get(ref) === "failed");
92
+ status = checkFailed || strengthFailed ? "failed" : "passed";
93
+ }
94
+ return {
95
+ id: scenario.id,
96
+ state_model_id: scenario.state_model_id,
97
+ transition_id: scenario.transition_id,
98
+ status,
99
+ stressor_refs: [...scenario.stressor_refs],
100
+ expected_strength_refs: [...scenario.expected_strength_refs],
101
+ check_refs: [...scenario.check_refs],
102
+ };
103
+ });
104
+ const scenarioStatus = new Map(scenarios.map((scenario) => [scenario.id, scenario.status]));
105
+ const stateModels = assurance.state_models.map((model) => ({
106
+ id: model.id,
107
+ version: model.version,
108
+ transitions: model.transitions.map((transition) => ({
109
+ id: transition.id,
110
+ from: transition.from,
111
+ event: transition.event,
112
+ to: transition.to,
113
+ status: transition.scenario_refs.some((ref) => scenarioStatus.get(ref) === "failed") ? "failed" :
114
+ transition.scenario_refs.some((ref) => ["requires_human", "requires_post_deploy"].includes(scenarioStatus.get(ref))) ? "skipped" : "passed",
115
+ scenario_refs: [...transition.scenario_refs],
116
+ })),
117
+ }));
118
+ return {
119
+ module_id: module.id,
120
+ module_version: module.version,
121
+ lifecycle: module.lifecycle.status,
122
+ severity: module.lifecycle.severity,
123
+ rule_results: ruleResults,
124
+ state_models: stateModels,
125
+ stressors,
126
+ strengths,
127
+ scenarios,
128
+ };
129
+ }
130
+
131
+ function assuranceContext(module, ruleId) {
132
+ const assurance = module.assurance;
133
+ if (!assurance) return { state_model_ids: [], transition_ids: [], stressor_ids: [], strength_ids: [], scenario_ids: [] };
134
+ const stressorIds = assurance.stressors.filter((item) => item.activation_check_refs.includes(ruleId)).map((item) => item.id);
135
+ const strengthIds = assurance.strengths.filter((item) => item.check_refs.includes(ruleId)).map((item) => item.id);
136
+ const scenarios = assurance.scenarios.filter((item) => item.check_refs.includes(ruleId) || item.stressor_refs.some((id) => stressorIds.includes(id)) || item.expected_strength_refs.some((id) => strengthIds.includes(id)));
137
+ const scenarioIds = scenarios.map((item) => item.id);
138
+ const stateModelIds = [...new Set(scenarios.map((item) => item.state_model_id))];
139
+ const transitionIds = [...new Set(scenarios.map((item) => item.transition_id))];
140
+ return { state_model_ids: stateModelIds, transition_ids: transitionIds, stressor_ids: stressorIds, strength_ids: strengthIds, scenario_ids: scenarioIds };
141
+ }
142
+
143
+ function moduleApplies(module, repository) {
144
+ const scoped = module.scope.repositories;
145
+ return !scoped?.length || scoped.some((item) => item.toLowerCase() === repository.toLowerCase());
146
+ }
147
+
148
+ async function matchModule(module, repository) {
149
+ for (const matcher of module.detection.matchers) {
150
+ if (!(await match(matcher, repository))) return false;
151
+ }
152
+ return true;
153
+ }
154
+
155
+ async function match(matcher, repository) {
156
+ const changed = repository.diff.filter((file) => matchesAny(matcher.paths, file.path));
157
+ if (matcher.type === "changed_path") return changed.length > 0;
158
+ const regex = safeRegex(matcher.pattern);
159
+ if (matcher.type === "diff_regex" || matcher.type === "reference") {
160
+ return changed.some((file) => file.lines.some((line) => sideMatches(matcher.side, line.side) && test(regex, line.text)));
161
+ }
162
+ if (matcher.type === "content_regex") {
163
+ const files = selectPaths(repository.paths, matcher.paths);
164
+ for (const file of files) if (test(regex, await readText(repository.root, file))) return true;
165
+ }
166
+ return false;
167
+ }
168
+
169
+ async function executeRule(rule, repository) {
170
+ switch (rule.type) {
171
+ case "diff_regex":
172
+ return diffRegex(rule, repository);
173
+ case "path_presence":
174
+ return pathPresence(rule, repository);
175
+ case "content_regex":
176
+ return contentRegex(rule, repository);
177
+ case "reference_definition":
178
+ return referenceDefinition(rule, repository);
179
+ default:
180
+ throw new Error(`Unsupported SQM checker type: ${rule.type}`);
181
+ }
182
+ }
183
+
184
+ function diffRegex(rule, repository) {
185
+ const regex = safeRegex(rule.pattern);
186
+ const matches = [];
187
+ for (const file of repository.diff.filter((item) => matchesAny(rule.paths || ["**"], item.path))) {
188
+ for (const line of file.lines) if (sideMatches(rule.side, line.side) && test(regex, line.text)) matches.push({ file: file.path, line: line.line, side: line.side, evidence: `${line.side}: ${line.text}` });
189
+ }
190
+ if (rule.expect === "present") return matches.length ? [] : [{ file: null, evidence: "Required diff pattern was not present." }];
191
+ return matches;
192
+ }
193
+
194
+ function pathPresence(rule, repository) {
195
+ const matches = selectPaths(repository.paths, rule.paths || []);
196
+ if (rule.expect === "present") return matches.length ? [] : [{ file: null, evidence: `Required repository path was not present: ${(rule.paths || []).join(", ")}` }];
197
+ return matches.map((file) => ({ file, evidence: `Forbidden repository path is present: ${file}` }));
198
+ }
199
+
200
+ async function contentRegex(rule, repository) {
201
+ const regex = safeRegex(rule.pattern);
202
+ const matches = [];
203
+ for (const file of selectPaths(repository.paths, rule.paths || ["**"])) {
204
+ const content = await readText(repository.root, file);
205
+ for (const result of matchLines(regex, content)) matches.push({ file, line: result.line, evidence: truncate(result.text) });
206
+ }
207
+ if (rule.expect === "present") return matches.length ? [] : [{ file: null, evidence: "Required content pattern was not present." }];
208
+ return matches;
209
+ }
210
+
211
+ async function referenceDefinition(rule, repository) {
212
+ const regex = safeRegex(rule.pattern);
213
+ const references = [];
214
+ for (const file of repository.diff.filter((item) => matchesAny(rule.paths || ["**"], item.path))) {
215
+ for (const line of file.lines.filter((item) => item.side === "added")) {
216
+ for (const match of allMatches(regex, line.text)) references.push({ value: match[1] || match[0], file: file.path, line: line.line });
217
+ }
218
+ }
219
+ const definitions = await Promise.all(selectPaths(repository.paths, rule.definition_paths || []).map((file) => readText(repository.root, file)));
220
+ return references
221
+ .filter((reference) => !definitions.some((content) => content.includes(reference.value)))
222
+ .map((reference) => ({ file: reference.file, line: reference.line, evidence: `Reference ${reference.value} has no definition in ${(rule.definition_paths || []).join(", ")}.` }));
223
+ }
224
+
225
+ function activeExceptionPatterns(module) {
226
+ const now = Date.now();
227
+ return (module.detection.exceptions || [])
228
+ .filter((item) => !item.expires_at || Date.parse(item.expires_at) > now)
229
+ .flatMap((item) => item.paths);
230
+ }
231
+
232
+ function selectPaths(paths, globs) {
233
+ return paths.filter((file) => matchesAny(globs, file)).slice(0, MAX_FILES_PER_PATTERN);
234
+ }
235
+
236
+ function matchesAny(globs, file) {
237
+ return globs.some((glob) => globMatches(glob, file));
238
+ }
239
+
240
+ export function globMatches(glob, file) {
241
+ const value = String(glob);
242
+ let source = "";
243
+ for (let index = 0; index < value.length; index += 1) {
244
+ const character = value[index];
245
+ if (character === "*" && value[index + 1] === "*" && value[index + 2] === "/") {
246
+ source += "(?:.*/)?";
247
+ index += 2;
248
+ } else if (character === "*" && value[index + 1] === "*") {
249
+ source += ".*";
250
+ index += 1;
251
+ } else if (character === "*") source += "[^/]*";
252
+ else if (character === "?") source += "[^/]";
253
+ else source += /[.+^${}()|[\]\\]/.test(character) ? `\\${character}` : character;
254
+ }
255
+ return new RegExp(`^${source}$`).test(file);
256
+ }
257
+
258
+ function safeRegex(pattern) {
259
+ let source = String(pattern || "");
260
+ let flags = "g";
261
+ const leadingFlags = source.match(/^\(\?([ims]+)\)/);
262
+ if (leadingFlags) {
263
+ flags += [...new Set(leadingFlags[1])].join("");
264
+ source = source.slice(leadingFlags[0].length);
265
+ }
266
+ if (source.length > 2_000 || /\\[1-9]|\(\?[=!<]|\([^)]*[+*][^)]*\)[+*{]/.test(source)) {
267
+ throw new Error("SQM regex uses an unsupported potentially unsafe construct.");
268
+ }
269
+ if (/\(\?[ims-]/.test(source)) {
270
+ throw new Error("SQM regex flags must appear once at the start of the pattern.");
271
+ }
272
+ return new RegExp(source, flags);
273
+ }
274
+
275
+ function test(regex, text) { regex.lastIndex = 0; return regex.test(text); }
276
+ function allMatches(regex, text) { regex.lastIndex = 0; return [...text.matchAll(regex)]; }
277
+ function sideMatches(expected, actual) { return !expected || expected === "either" || expected === actual; }
278
+ function truncate(value) { const text = String(value); return text.length > 300 ? `${text.slice(0, 297)}...` : text; }
279
+ function sha256(value) { return createHash("sha256").update(String(value)).digest("hex"); }
280
+
281
+ function matchLines(regex, content) {
282
+ const results = [];
283
+ for (const [index, line] of content.split(/\r?\n/).entries()) if (test(regex, line)) results.push({ line: index + 1, text: line });
284
+ return results;
285
+ }
286
+
287
+ async function readText(root, relative) {
288
+ const resolved = path.resolve(root, relative);
289
+ if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) throw new Error("SQM path escaped the repository root.");
290
+ try {
291
+ const fileStat = await lstat(resolved);
292
+ if (!fileStat.isFile() || fileStat.isSymbolicLink()) return "";
293
+ const buffer = await readFile(resolved);
294
+ if (buffer.length > MAX_FILE_BYTES || buffer.includes(0)) return "";
295
+ return buffer.toString("utf8");
296
+ } catch {
297
+ return "";
298
+ }
299
+ }
@@ -0,0 +1,149 @@
1
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { isSessionValid, readSession } from "../session-store.js";
6
+ import { resolveWebOrigin } from "../web-origin.js";
7
+ import { uploadRunEvidence } from "./evidence-client.js";
8
+ import { runSqmCheck, runSqmMinCheck } from "./index.js";
9
+ import { ensureSqmSession } from "./session.js";
10
+
11
+ export async function runSqmCommand(argv, { cliVersion }) {
12
+ if (argv.includes("--help") || argv.includes("-h") || !argv.length) {
13
+ printHelp();
14
+ return 0;
15
+ }
16
+ if (!["min-check", "check"].includes(argv[0])) throw new Error("Usage: simy sqm <min-check|check> [options]");
17
+ const webOrigin = resolveWebOrigin(option(argv, "--host"));
18
+ let session = await readSession(webOrigin);
19
+ let validSession = isSessionValid(session, Date.now(), webOrigin) ? session : null;
20
+ if (!validSession && !explicitSqmEnvironment(argv)) {
21
+ session = await ensureSqmSession(webOrigin, { noOpen: argv.includes("--no-open") });
22
+ validSession = session;
23
+ }
24
+ const organizationId = process.env.SIMY_SQM_ORGANIZATION_ID || validSession?.organization_id || validSession?.org_id;
25
+ const token = process.env.SIMY_SQM_TOKEN || process.env.SIMY_ACCESS_TOKEN || validSession?.token;
26
+ const accountId = process.env.SIMY_SQM_ACCOUNT_ID || validSession?.account_id || validSession?.auth_user_id || validSession?.device_id;
27
+ const deviceId = process.env.SIMY_SQM_DEVICE_ID || validSession?.device_id;
28
+ if (!organizationId) throw new Error(`SQM needs an organization-bound SIMY session. Run \`simy --host ${webOrigin}\` once, then retry.`);
29
+ const endpoint = process.env.SIMY_SQM_BUNDLE_URL || (validSession?.api_base_url ? new URL("sqm/knowledge-bundle", validSession.api_base_url).href : undefined);
30
+ const common = {
31
+ cwd: process.cwd(),
32
+ baseBranch: option(argv, "--base") || "dev",
33
+ cliVersion,
34
+ offline: argv.includes("--offline"),
35
+ refresh: argv.includes("--refresh"),
36
+ token,
37
+ endpoint,
38
+ accountId,
39
+ organizationId,
40
+ };
41
+
42
+ if (argv[0] === "min-check") {
43
+ const result = await runSqmMinCheck(common);
44
+ const output = option(argv, "--output") || defaultMinResultPath();
45
+ await atomicJson(output, result);
46
+ if (argv.includes("--json")) console.log(JSON.stringify(result, null, 2));
47
+ else printMin(result, output);
48
+ return result.status === "unavailable" ? 2 : result.status === "preliminary_failed" ? 1 : 0;
49
+ }
50
+
51
+ const minPath = option(argv, "--min-result") || defaultMinResultPath();
52
+ let minCheck;
53
+ try {
54
+ minCheck = JSON.parse(await readFile(minPath, "utf8"));
55
+ } catch (error) {
56
+ if (error?.code !== "ENOENT") throw error;
57
+ console.warn("No min-check result was found; running the deterministic min-check first.");
58
+ minCheck = await runSqmMinCheck(common);
59
+ await atomicJson(minPath, minCheck);
60
+ }
61
+ const result = await runSqmCheck({ ...common, minCheck, deviceId, signingKeyPath: option(argv, "--signing-key") || undefined });
62
+ const proofPath = path.resolve(option(argv, "--proof") || "proof.json");
63
+ const evidencePath = path.resolve(option(argv, "--signed-evidence") || `${proofPath}.signed.json`);
64
+ await Promise.all([atomicJson(proofPath, result.proof), atomicJson(evidencePath, result.signed_evidence)]);
65
+ let upload = { uploaded: false, reason: "disabled" };
66
+ if (!argv.includes("--no-upload")) {
67
+ const evidenceEndpoint = process.env.SIMY_SQM_EVIDENCE_URL || (validSession?.api_base_url ? new URL("sqm/run-evidence", validSession.api_base_url).href : undefined);
68
+ upload = await uploadRunEvidence({ endpoint: evidenceEndpoint, token, proof: result.proof, signedEvidence: result.signed_evidence });
69
+ }
70
+ result.upload = upload;
71
+ result.artifacts = { min_check: minPath, proof: proofPath, signed_evidence: evidencePath };
72
+ if (argv.includes("--json")) console.log(JSON.stringify(result, null, 2));
73
+ else printFull(result);
74
+ return result.proof.outcome.status === "failed" ? 1 : 0;
75
+ }
76
+
77
+ function printHelp() {
78
+ console.log(`Usage:
79
+ simy sqm min-check [options]
80
+ simy sqm check [options]
81
+
82
+ Two-stage SQM flow:
83
+ min-check Fast deterministic diff preflight and full-check execution plan.
84
+ check Full state/invariant/stress/strength/scenario verification. It
85
+ verifies the pinned min-check identity, writes proof.json, and
86
+ produces independent per-run signed evidence.
87
+
88
+ Options:
89
+ --base <branch> Compare against this base branch (default: dev)
90
+ --host <origin> SIMY Web origin used for the existing device session
91
+ --offline Use only the last verified cached knowledge bundle
92
+ --refresh Revalidate the signed bundle
93
+ --no-open Print the sign-in URL instead of opening a browser
94
+ --output <path> min-check result path
95
+ --min-result <path> full check input (default: the last local min-check)
96
+ --proof <path> proof.json output (default: ./proof.json)
97
+ --signed-evidence <path> per-run signature output
98
+ --no-upload keep proof local instead of uploading hashes/results
99
+ --json print machine-readable output
100
+
101
+ Neither phase executes Cloud-provided code or calls a local LLM.`);
102
+ }
103
+
104
+ function explicitSqmEnvironment(argv) {
105
+ const organization = process.env.SIMY_SQM_ORGANIZATION_ID?.trim();
106
+ const account = process.env.SIMY_SQM_ACCOUNT_ID?.trim();
107
+ if (argv.includes("--offline")) return Boolean(organization && account);
108
+ const token = (process.env.SIMY_SQM_TOKEN || process.env.SIMY_ACCESS_TOKEN)?.trim();
109
+ const endpoint = process.env.SIMY_SQM_BUNDLE_URL?.trim();
110
+ return Boolean(organization && token && endpoint);
111
+ }
112
+
113
+ function printMin(result, output) {
114
+ for (const warning of result.warnings || []) console.warn(`warning: ${warning}`);
115
+ if (result.status === "unavailable") return;
116
+ console.log(`SQM min-check: ${result.status} · ${result.preliminary_findings.length} preliminary finding(s)`);
117
+ console.log(`Pinned bundle ${result.knowledge_bundle.id}@${result.knowledge_bundle.version} (${result.knowledge_bundle.digest})`);
118
+ console.log(`Full-check plan: ${result.full_check_plan.length} scenario(s)`);
119
+ console.log(`Result: ${output}`);
120
+ }
121
+
122
+ function printFull(result) {
123
+ console.log(`SQM full check: ${result.proof.outcome.status} · ${result.findings.length} finding(s)`);
124
+ console.log(`Proof ${result.proof.proof_id} (${result.proof.integrity.digest})`);
125
+ console.log(`Per-run signature: valid · ${result.signature_verification.key_id}`);
126
+ console.log(`Artifacts: ${result.artifacts.proof} · ${result.artifacts.signed_evidence}`);
127
+ if (result.upload.uploaded) console.log(`Cloud evidence: stored · ${result.upload.evidence_id || result.signed_evidence.evidence_id}`);
128
+ else console.log(`Cloud evidence: local only (${result.upload.reason})`);
129
+ }
130
+
131
+ function defaultMinResultPath() {
132
+ return path.join(process.env.SIMY_HOME?.trim() || path.join(homedir(), ".simy"), "sqm", "min-check.json");
133
+ }
134
+
135
+ async function atomicJson(target, value) {
136
+ const resolved = path.resolve(target);
137
+ await mkdir(path.dirname(resolved), { recursive: true, mode: 0o700 });
138
+ const temporary = `${resolved}.${process.pid}.tmp`;
139
+ await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
140
+ await rename(temporary, resolved);
141
+ }
142
+
143
+ function option(argv, name) {
144
+ const index = argv.indexOf(name);
145
+ if (index < 0) return null;
146
+ const value = argv[index + 1];
147
+ if (!value || value.startsWith("--")) throw new Error(`${name} requires a value.`);
148
+ return value;
149
+ }
@@ -0,0 +1,12 @@
1
+ export async function uploadRunEvidence({ endpoint, token, proof, signedEvidence, fetchImpl = globalThis.fetch }) {
2
+ if (!endpoint || !token) return { uploaded: false, reason: "authenticated_cloud_session_unavailable" };
3
+ const response = await fetchImpl(endpoint, {
4
+ method: "POST",
5
+ headers: { accept: "application/json", authorization: `Bearer ${token}`, "content-type": "application/json" },
6
+ body: JSON.stringify({ proof, signed_evidence: signedEvidence }),
7
+ signal: AbortSignal.timeout(10_000),
8
+ });
9
+ const payload = await response.json().catch(() => null);
10
+ if (!response.ok) throw new Error(`SQM run evidence upload failed with HTTP ${response.status}${payload?.error ? `: ${typeof payload.error === "string" ? payload.error : payload.error.message || "unknown error"}` : ""}.`);
11
+ return { uploaded: true, ...payload };
12
+ }
@@ -0,0 +1,141 @@
1
+ import { evaluateKnowledgeBundle } from "./checkers.js";
2
+ import { loadKnowledgeBundle } from "./bundle-store.js";
3
+ import { uploadRunEvidence } from "./evidence-client.js";
4
+ import { buildProof, finalizeMinCheck, repositoryIdentity, sameRepositoryIdentity, signProof, verifyMinCheck, verifySignedEvidence } from "./proof.js";
5
+ import { inspectRepository } from "./repository.js";
6
+
7
+ export function createSqmChecker({
8
+ cwd = process.cwd(),
9
+ baseBranch = "dev",
10
+ cliVersion,
11
+ offline = false,
12
+ refresh = false,
13
+ token,
14
+ endpoint,
15
+ accountId,
16
+ organizationId,
17
+ deviceId,
18
+ evidenceEndpoint,
19
+ uploadRunEvidenceImpl = uploadRunEvidence,
20
+ inspectRepositoryImpl = inspectRepository,
21
+ loadKnowledgeBundleImpl = loadKnowledgeBundle,
22
+ evaluateKnowledgeBundleImpl = evaluateKnowledgeBundle,
23
+ executeKnowledgeBundleImpl,
24
+ ...dependencies
25
+ }) {
26
+ let pinnedLoad = null;
27
+ let pinnedRepository = null;
28
+ let latestMinCheck = null;
29
+
30
+ return async function checkCurrentRepository(_attempt = null, { phase = "min" } = {}) {
31
+ let repository;
32
+ try {
33
+ repository = await inspectRepositoryImpl(cwd, { baseBranch, runCommand: dependencies.runCommand });
34
+ } catch (error) {
35
+ return unavailable(`SQM repository inspection failed: ${message(error)}`);
36
+ }
37
+ if (pinnedRepository && pinnedRepository !== repository.repository) return unavailable(`SQM repository changed during a pinned run (${pinnedRepository} to ${repository.repository}).`, repository.repository);
38
+ if (!pinnedLoad) {
39
+ pinnedRepository = repository.repository;
40
+ pinnedLoad = loadKnowledgeBundleImpl({ repository: repository.repository, cliVersion, offline, refresh, token, endpoint, accountId, organizationId, ...dependencies });
41
+ }
42
+ const loaded = await pinnedLoad;
43
+ if (!loaded.bundle) return { ...unavailable(loaded.warnings[0], repository.repository), warnings: loaded.warnings };
44
+ try {
45
+ const evaluate = executeKnowledgeBundleImpl
46
+ ? async (bundle, currentRepository) => ({ findings: await executeKnowledgeBundleImpl(bundle, currentRepository), modules: [] })
47
+ : evaluateKnowledgeBundleImpl;
48
+ if (phase === "full") {
49
+ if (!latestMinCheck || !sameRepositoryIdentity(latestMinCheck.repository_identity, repositoryIdentity(repository))) {
50
+ latestMinCheck = await buildMinCheck({ repository, bundle: loaded.bundle, evaluation: await evaluate(loaded.bundle, repository), organizationId: organizationId || loaded.bundle.organization_id, cliVersion });
51
+ }
52
+ const full = await buildFullCheck({ repository, bundle: loaded.bundle, minCheck: latestMinCheck, evaluation: await evaluate(loaded.bundle, repository), organizationId: organizationId || loaded.bundle.organization_id, cliVersion, deviceId, accountId });
53
+ try {
54
+ full.upload = await uploadRunEvidenceImpl({ endpoint: evidenceEndpoint, token, proof: full.proof, signedEvidence: full.signed_evidence });
55
+ } catch (error) {
56
+ full.upload = { uploaded: false, reason: `upload_failed: ${message(error)}` };
57
+ full.warnings.push(`SQM signed evidence remains local because Cloud upload failed: ${message(error)}`);
58
+ }
59
+ return full;
60
+ }
61
+ latestMinCheck = await buildMinCheck({ repository, bundle: loaded.bundle, evaluation: await evaluate(loaded.bundle, repository), organizationId: organizationId || loaded.bundle.organization_id, cliVersion });
62
+ return toLegacyCheck(latestMinCheck, loaded);
63
+ } catch (error) {
64
+ return { ...unavailable(`SQM ${phase} execution failed: ${message(error)}`, repository.repository), bundle: bundleIdentity(loaded.bundle), warnings: [...loaded.warnings, `SQM ${phase} execution failed: ${message(error)}`] };
65
+ }
66
+ };
67
+ }
68
+
69
+ export async function runSqmMinCheck(options) {
70
+ const context = await loadContext(options);
71
+ if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings };
72
+ const evaluation = await (options.evaluateKnowledgeBundleImpl || evaluateKnowledgeBundle)(context.loaded.bundle, context.repository);
73
+ return buildMinCheck({ repository: context.repository, bundle: context.loaded.bundle, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion });
74
+ }
75
+
76
+ export async function runSqmCheck(options) {
77
+ const context = await loadContext(options);
78
+ if (!context.loaded.bundle) return { ...unavailable(context.loaded.warnings[0], context.repository.repository), warnings: context.loaded.warnings };
79
+ verifyMinCheck(options.minCheck);
80
+ if (!sameRepositoryIdentity(options.minCheck.repository_identity, repositoryIdentity(context.repository))) throw new Error("Repository identity changed after min-check; run `simy sqm min-check` again.");
81
+ if (options.minCheck.knowledge_bundle.digest !== context.loaded.bundle.integrity.digest) throw new Error("Knowledge bundle changed after min-check; run `simy sqm min-check` again so both phases use one pinned bundle.");
82
+ const evaluation = await (options.evaluateKnowledgeBundleImpl || evaluateKnowledgeBundle)(context.loaded.bundle, context.repository);
83
+ return buildFullCheck({ repository: context.repository, bundle: context.loaded.bundle, minCheck: options.minCheck, evaluation, organizationId: options.organizationId, cliVersion: options.cliVersion, deviceId: options.deviceId, accountId: options.accountId, signingKeyPath: options.signingKeyPath });
84
+ }
85
+
86
+ async function loadContext(options) {
87
+ const repository = await (options.inspectRepositoryImpl || inspectRepository)(options.cwd || process.cwd(), { baseBranch: options.baseBranch || "dev", runCommand: options.runCommand });
88
+ const loaded = await (options.loadKnowledgeBundleImpl || loadKnowledgeBundle)({ repository: repository.repository, cliVersion: options.cliVersion, offline: options.offline, refresh: options.refresh, token: options.token, endpoint: options.endpoint, accountId: options.accountId, organizationId: options.organizationId, ...(options.bundleDependencies || {}) });
89
+ return { repository, loaded };
90
+ }
91
+
92
+ async function buildMinCheck({ repository, bundle, evaluation, organizationId, cliVersion }) {
93
+ const plan = evaluation.modules.flatMap((module) => module.scenarios.map((scenario) => ({ module_id: module.module_id, scenario_id: scenario.id, state_model_id: scenario.state_model_id, transition_id: scenario.transition_id, stressor_refs: scenario.stressor_refs, expected_strength_refs: scenario.expected_strength_refs, execution_requirement: scenario.status === "requires_human" ? "requires_human" : scenario.status === "requires_post_deploy" ? "requires_post_deploy" : "local" })));
94
+ return finalizeMinCheck({
95
+ schema_version: "1.0.0",
96
+ kind: "sqm_min_check",
97
+ organization_id: organizationId,
98
+ repository_identity: repositoryIdentity(repository),
99
+ knowledge_bundle: bundleIdentity(bundle),
100
+ executed_at: new Date().toISOString(),
101
+ cli_version: cliVersion,
102
+ status: evaluation.findings.length ? "preliminary_failed" : "passed",
103
+ preliminary_findings: evaluation.findings,
104
+ matched_modules: evaluation.modules.map((module) => ({ module_id: module.module_id, module_version: module.module_version, state_model_ids: module.state_models.map((model) => model.id), transition_ids: module.state_models.flatMap((model) => model.transitions.map((transition) => transition.id)), stressor_ids: module.stressors.map((stressor) => stressor.id), scenario_ids: module.scenarios.map((scenario) => scenario.id) })),
105
+ full_check_plan: plan,
106
+ });
107
+ }
108
+
109
+ async function buildFullCheck({ repository, bundle, minCheck, evaluation, organizationId, cliVersion, deviceId, accountId, signingKeyPath }) {
110
+ const proof = buildProof({ organizationId, repository, bundle, minCheck, evaluation, findings: evaluation.findings, cliVersion });
111
+ const signedEvidence = await signProof(proof, { deviceId, accountId, keyPath: signingKeyPath });
112
+ const verification = verifySignedEvidence(proof, signedEvidence);
113
+ return {
114
+ status: "checked",
115
+ mode: "full",
116
+ repository: repository.repository,
117
+ base_ref: repository.base_ref,
118
+ bundle: bundleIdentity(bundle),
119
+ min_check: minCheck,
120
+ findings: evaluation.findings,
121
+ executions: evaluation.modules,
122
+ proof,
123
+ signed_evidence: signedEvidence,
124
+ signature_verification: verification,
125
+ warnings: [],
126
+ };
127
+ }
128
+
129
+ function toLegacyCheck(minCheck, loaded) {
130
+ return { status: "checked", mode: "min", repository: minCheck.repository_identity.repository, base_ref: minCheck.repository_identity.base_ref, source: loaded.source, warnings: loaded.warnings, bundle: minCheck.knowledge_bundle, findings: minCheck.preliminary_findings, min_check: minCheck };
131
+ }
132
+
133
+ function bundleIdentity(bundle) {
134
+ return { id: bundle.bundle_id, version: bundle.version, digest: bundle.integrity.digest, signing_key_id: bundle.integrity.key_id };
135
+ }
136
+
137
+ function unavailable(warning, repository = null) {
138
+ return { status: "unavailable", mode: "shadow", repository, bundle: null, findings: [], warnings: [warning] };
139
+ }
140
+
141
+ function message(error) { return error instanceof Error ? error.message : String(error); }